feat: plan mode with model-driven entry and collaborative review (#1580)

* feat: add plan mode for read-only research and planning

Adds a per-run plan_mode flag that puts the agent in a read-only
research phase: a strong prompt section is injected and mutating tools
are stripped via ExcludeToolsMiddleware so the agent proposes a
reviewable implementation plan before any edits. Surfaced in the
dashboard UI with a Plan toggle (Shift+Tab) wired through the thread API.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: enforce plan-mode read-only at tool layer and disable subagents

Addresses PR review: plan mode previously relied on prompt text to keep
the shell read-only and left the task subagent (built with its own
write/PR/Linear tools) unrestricted. Now `task` is excluded so research
cannot be delegated to a mutating subagent, and a new
PlanModeShellGuardMiddleware enforces a read-only command allowlist on
`execute`, blocking writes, git state changes, installs, redirection,
and command substitution regardless of model/prompt-injection compliance.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: harden plan-mode shell guard against wrapped mutations

Block git global options that take values (-C, --git-dir, ...) from being
misread as the subcommand, reject config-injection options (-c,
--config-env, --exec-path), and drop the env command wrapper that could
run arbitrary commands.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* feat: add plan mode with enter_plan_mode tool, profile/team defaults, Slack commands and approval flow

- enter_plan_mode tool: agent self-activates plan mode via Command(update={'plan_mode': True})
- Plan mode resolution: per-thread > profile default > team default > False
- PLAN_MODE_GUIDANCE_SECTION: always-present prompt section telling agent about the tool
- profile_plan_mode_default and team plan_mode_default settings
- Slack plan on/off/status commands with thread metadata persistence
- slack_thread_reply plan_approval=True renders Approve/Revise/Cancel buttons
- Interactivity handler: approve triggers implementation run, cancel posts confirmation
- Frontend: plan_mode_default in Profile/ProfileUpdate/TeamSettings types and UI toggles

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* test: add tests for enter_plan_mode tool, profile/team defaults, Slack plan commands, approval blocks

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* refactor(plan-mode): drop shell guard, rely on prompt for read-only discipline

Remove PlanModeShellGuardMiddleware and its enforcement of read-only shell
commands during plan mode. Plan mode now relies on the system prompt to
instruct the agent not to run mutating commands; the mutating-tool exclusion
(ExcludeToolsMiddleware) is retained.

* test(open-swe): add Playwright E2E for the Slack → PR → web handoff

Local, secrets-free end-to-end suite that drives the full happy path through mock Slack/GitHub control panels and the real dashboard UI. Only the LLM and external SaaS HTTP boundaries (GitHub/Slack APIs, OAuth token mint) are faked — the real process_slack_mention, get_agent, deepagents loop, tools, middleware, and dashboard authorization all run under `langgraph dev` with a scripted fake chat model and a local temp-dir sandbox.

- full_flow: a Slack mention runs the agent, which implements a change in the sandbox, opens a PR against a fake GitHub remote, and replies with the PR link in the same thread.
- dashboard: clicking the bot's real "Open in Web" link loads the built ui/ app (served same-origin); the thread owner can continue the conversation, while a different user sees the same thread read-only (no composer).

Wired into Agent CI as a `Playwright E2E` job that runs on pull requests.

* fix(open-swe): serve E2E UI assets via explicit route; pin Playwright

The dashboard E2E served the built ui/ SPA's /assets via app.mount(StaticFiles), but LangGraph's custom-app loader serves APIRoutes and drops sub-app Mounts, so /assets 404'd under `langgraph dev` in CI — the React app never booted and the composer/transcript never rendered. Serve assets via an explicit route instead.

Also pin @playwright/test to the latest (1.61.0) for reproducible runs, and make the owner composer assertion tolerant of either hydration state.

* test(open-swe): record Playwright trace + video on every E2E run

Capture a replayable trace (DOM snapshots, network, console, source) and a screen recording for every test, not just retries, plus a screenshot on failure. The CI job already uploads playwright-report/ and test-results/, so each run now has a downloadable replay; documented how to open it.

* feat(plan-mode): collaborative plan review with BlockNote + Yjs

When the agent enters plan mode it writes the plan as a markdown file in the
sandbox (save_plan tool), publishes it, and posts a review link to the source
channel. Reviewers open the plan inside the dashboard (under the /agents shell),
read it rendered in a BlockNote editor, and leave inline comments synced live
over Yjs. Only the thread owner can approve; any reviewer can request changes.
On approve/reject the comments are harvested and handed to the agent for the
follow-up run; the agent never sees comments mid-review.

- agent: enter_plan_mode persists plan state; new save_plan tool; prompt shares
  the plan-review link.
- dashboard: Yjs WebSocket collab server (pycrdt-websocket) with store-backed
  snapshots; plan content/status store; plan REST API (get/approve/reject,
  owner-only approve, client-harvested comments); planStatus on thread summaries.
- ui: BlockNote native comments (CommentsExtension + YjsThreadStore) plan page
  mounted under the agents shell, with a "Review plan" banner in the thread view
  and a back-link; theme-aware (dark mode) using the dashboard tokens.
- e2e: Playwright coverage of the full Slack -> plan -> review -> approve -> PR
  flow, including cross-user comment sync and owner-only approval.

* fix(plan-mode): address review feedback (authz, overrides, leaks, deps)

- plan-collab WS: authorize per-thread before joining a room (same read gate as
  the REST API) — previously any logged-in user could join any thread (IDOR).
- plan-collab: tie the snapshot flusher to active connections (refcount) so each
  opened plan no longer leaks a permanent 1.5s task on the shared event loop.
- plan decisions: include thread_id in the follow-up run configurable so the run
  resumes the existing thread; set plan_mode explicitly so approve forces it off.
- get_agent: an explicit per-thread plan_mode (Slack `plan off`, approved plan,
  dashboard toggle) now overrides profile/team defaults instead of falling back.
- plan mode tool gating moved to a state-aware PlanModeMiddleware installed
  unconditionally, so a mid-run enter_plan_mode restricts the next model turn;
  before_agent resets stale plan_mode so a later run isn't forced back into it.
- exclude write-capable http_request from plan mode.
- pin pycrdt / pycrdt-websocket with upper bounds.

Includes the latest base (#1583): E2E UI assets served via explicit route
(fixes the Playwright CI failure — LangGraph's app loader drops sub-app mounts).

* style: ruff format plan_collab.py

* fix(plan-mode): owner-gate Slack approval + same-origin check on collab WS

- Slack "Approve & Implement" now verifies the clicking user is the plan
  requester (owner, via the stored triggering_user_id) before implementing —
  matching the dashboard API's owner-only approval. Non-owners are pointed to
  Revise / feedback.
- The plan-collab WebSocket validates the handshake Origin against the dashboard
  allowlist before accept() (no-op when unconfigured, e.g. local/dev), mirroring
  the REST require_same_origin CSRF defense.

* fix(plan-mode): enter plan mode only via the model + local mock dev harness

Plan mode is now entered solely when the model calls enter_plan_mode.
Removed the per-user and team plan_mode_default settings (backend + UI)
and the Slack `plan on/off/status` toggle.

- enter_plan_mode returns a terminating ToolMessage, fixing the missing
  ToolMessage error that silently dropped plan mode mid-run.
- PlanReview: defer Yjs provider/doc teardown so React StrictMode's dev
  remount doesn't destroy and then reuse the collaboration provider.
- e2e plan_review spec asserts plan_mode actually engages.
- LangSmith trace-url resolution is best-effort: bail before any API
  call when the tenant is unset, cache failures, log at debug.
- Add `pnpm run dev:mock`: same-origin Vite HMR harness with a real LLM,
  Alice/Bob mock users, and a GitHub login picker.

* docs(plan-mode): drop stale references to removed profile/team defaults

The plan_mode middleware docstring and the approve/reject dispatch comment
still described the profile/team plan_mode_default resolution that no longer
exists; reword to match model-driven entry + the per-thread carry.

* feat(plan-mode): let any reviewer edit the plan, not just comment

Drop the owner/commenter split for the plan document: everyone with read
access edits and comments alike (DefaultThreadStoreAuth "editor" for all,
editor always editable until a decision, anyone seeds the empty doc). This
matches the collab WS, which already relays frames to every readable user.
Plan approval stays owner-gated.

* test(plan-mode): assert plan-mode entry via the tool's success message

plan_mode lives only in run state for tool gating; it is not a persisted
thread-state channel, so the previous `values.plan_mode === true` poll
could never pass. Assert instead that enter_plan_mode's success ToolMessage
("Plan mode is active …") lands in the thread — which only happens when the
tool's Command applies cleanly, the exact regression this guards.

---------

Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Ramon Nogueira 2026-06-23 15:06:58 -04:00 • committed by GitHub
parent eac293d8a7
commit 3a0e2b4672
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2904 additions and 80 deletions

188
agent/dashboard/plan_api.py Normal file
View file

@ -0,0 +1,188 @@
"""REST API for the plan-review page: read the plan, approve, or request changes.
Comment threads themselves live in the collaborative Yjs document (BlockNote's
``YjsThreadStore``) and are harvested client-side; on approve/reject the client
sends the harvested feedback here, where it is formatted and handed to the agent
as the instruction for the follow-up run. The agent never sees comments during
review — only this aggregated feedback at the decision point.
Permissions: any authenticated org member can read a surfaced thread and request
changes (reject); only the thread owner can approve.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from langgraph_sdk import get_client
from pydantic import BaseModel, Field
from .oauth import require_same_origin_for_mutations, require_session
from .plan_store import (
PLAN_STATUS_APPROVED,
PLAN_STATUS_REVISING,
get_plan_content,
set_plan_status,
)
from .thread_api import (
_repo_config_from_metadata,
_thread_is_readable,
_thread_source,
_user_owns_thread,
)
logger = logging.getLogger(__name__)
plan_router = APIRouter(
prefix="/dashboard/api/plan",
tags=["plan"],
dependencies=[Depends(require_same_origin_for_mutations)],
)
_SESSION_DEP = Depends(require_session)
class PlanComment(BaseModel):
author: str | None = None
body: str = ""
quote: str | None = None
resolved: bool = False
class PlanDecisionBody(BaseModel):
comments: list[PlanComment] = Field(default_factory=list)
async def _thread_metadata(thread_id: str) -> dict[str, Any]:
client = get_client()
try:
thread = await client.threads.get(thread_id)
except Exception as exc: # noqa: BLE001
raise HTTPException(404, "thread not found") from exc
metadata = (
thread.get("metadata") if isinstance(thread, dict) else getattr(thread, "metadata", None)
)
return metadata if isinstance(metadata, dict) else {}
@plan_router.get("/{thread_id}")
async def get_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
metadata = await _thread_metadata(thread_id)
if not _thread_is_readable(metadata):
raise HTTPException(404, "thread not found")
login = session["sub"]
email = session.get("email")
content = await get_plan_content(thread_id) or {}
return {
"threadId": thread_id,
"status": content.get("status") or metadata.get("plan_status") or "planning",
"markdown": content.get("markdown", ""),
"isOwner": _user_owns_thread(metadata, login, email),
"user": {
"id": login,
"login": login,
"email": email,
"name": session.get("name") or login,
},
}
@plan_router.post("/{thread_id}/approve")
async def approve_plan(
thread_id: str,
body: PlanDecisionBody,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
metadata = await _thread_metadata(thread_id)
if not _user_owns_thread(metadata, session["sub"], session.get("email")):
raise HTTPException(403, "only the plan owner can approve")
await set_plan_status(thread_id, PLAN_STATUS_APPROVED, plan_mode=False)
feedback = _format_comments(body.comments)
if feedback:
text = (
"The plan has been approved. Implement it now, taking this reviewer "
f"feedback into account:\n\n{feedback}"
)
else:
text = "The plan has been approved. Implement it now as described in the plan."
await _dispatch_followup(thread_id, metadata, text, plan_mode=False)
return {"status": PLAN_STATUS_APPROVED}
@plan_router.post("/{thread_id}/reject")
async def reject_plan(
thread_id: str,
body: PlanDecisionBody,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
metadata = await _thread_metadata(thread_id)
if not _thread_is_readable(metadata):
raise HTTPException(404, "thread not found")
await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True)
feedback = _format_comments(body.comments)
text = (
"The plan needs changes before implementation. Address this reviewer "
"feedback and publish an updated plan with the save_plan tool:\n\n"
f"{feedback or '(no specific comments were left)'}"
)
await _dispatch_followup(thread_id, metadata, text, plan_mode=True)
return {"status": PLAN_STATUS_REVISING}
def _format_comments(comments: list[PlanComment]) -> str:
lines: list[str] = []
for index, comment in enumerate(comments, start=1):
body = comment.body.strip()
if not body:
continue
author = (comment.author or "reviewer").strip()
quote = (comment.quote or "").strip()
status = " (resolved)" if comment.resolved else ""
if quote:
lines.append(f'{index}. On "{quote}"{status}:\n - {author}: {body}')
else:
lines.append(f"{index}. {author}{status}: {body}")
return "\n".join(lines)
async def _dispatch_followup(
thread_id: str, metadata: dict[str, Any], text: str, *, plan_mode: bool
) -> None:
"""Continue the existing thread with a new instruction run.
Runs on the same LangGraph thread, so the agent resumes from the checkpoint
with the full planning history plus this instruction. The configurable is
rebuilt from the thread's stored owner/repo/Slack context so the agent can
push, open a PR, and reply in the original channel.
"""
configurable: dict[str, Any] = {
"thread_id": thread_id,
"source": _thread_source(metadata) or "slack",
}
email = metadata.get("triggering_user_email")
if isinstance(email, str) and email:
configurable["user_email"] = email
login = metadata.get("github_login")
if isinstance(login, str) and login:
configurable["github_login"] = login
repo = _repo_config_from_metadata(metadata)
if repo:
configurable["repo"] = repo
source_context = metadata.get("source_context")
if isinstance(source_context, dict):
slack_thread = source_context.get("slack_thread")
if isinstance(slack_thread, dict):
configurable["slack_thread"] = slack_thread
# Carry the decision to the follow-up run: approve continues out of plan
# mode (implement), reject stays in plan mode (revise the plan).
configurable["plan_mode"] = plan_mode
client = get_client()
await client.runs.create(
thread_id,
"agent",
input={"messages": [{"role": "user", "content": text}]},
config={"configurable": configurable},
if_not_exists="create",
)

View file

@ -0,0 +1,178 @@
"""Yjs collaboration server for the plan-review document.
A single :class:`WebsocketServer` (started for the app lifetime via
:func:`collab_lifespan`) relays Yjs sync/awareness frames between the reviewers
connected to a plan. Each plan is a room keyed by ``thread_id``; the room's
document is seeded from / snapshotted to the LangGraph store so a plan and its
comment threads survive a restart.
The browser connects with the standard ``y-websocket`` client, which appends the
room name to the provider URL — hence the ``/yjs/{thread_id}`` route shape.
Any user who can read the thread can both edit the plan and comment — there is
no document-level owner/commenter split (only plan *approval* is owner-gated, in
the REST API). So relaying frames to every readable connection is intentional.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from langgraph_sdk import get_client
from pycrdt.websocket import WebsocketServer, YRoom
from pycrdt.websocket.websocket import HttpxWebsocket
from .oauth import COOKIE_NAME, _origin_of, allowed_dashboard_origins, decode_session
from .plan_store import load_yjs_snapshot, save_yjs_snapshot
from .thread_api import _thread_is_readable
logger = logging.getLogger(__name__)
collab_router = APIRouter(prefix="/dashboard/api/plan", tags=["plan-collab"])
_SNAPSHOT_DEBOUNCE_SECONDS = 1.5
_server: WebsocketServer | None = None
_dirty: set[str] = set()
# Per-room state, alive only while at least one reviewer is connected.
_connections: dict[str, int] = {}
_flushers: dict[str, asyncio.Task[None]] = {}
_subscriptions: dict[str, Any] = {}
@asynccontextmanager
async def collab_lifespan() -> AsyncIterator[None]:
"""Run the Yjs collaboration server for the app's lifetime."""
global _server
async with WebsocketServer(auto_clean_rooms=False) as server:
_server = server
try:
yield
finally:
_server = None
for task in _flushers.values():
task.cancel()
_flushers.clear()
_subscriptions.clear()
_connections.clear()
_dirty.clear()
def _session_login(websocket: WebSocket) -> str | None:
token = websocket.cookies.get(COOKIE_NAME)
if not token:
return None
try:
session = decode_session(token)
except Exception:
return None
sub = session.get("sub") if isinstance(session, dict) else None
return sub if isinstance(sub, str) and sub else None
def _origin_allowed(websocket: WebSocket) -> bool:
"""Same-origin (CSRF) gate for the WS handshake, mirroring the REST
``require_same_origin`` check: a no-op when no dashboard origins are
configured (local/dev), otherwise the handshake ``Origin`` must be in the
allowlist so a malicious site can't open a cross-site socket on the
victim's cookie."""
allowed = allowed_dashboard_origins()
if not allowed:
return True
origin = _origin_of(websocket.headers.get("origin", "") or "")
return bool(origin) and origin in allowed
async def _can_read_thread(thread_id: str) -> bool:
"""Same read gate as the plan REST API: only surfaced threads are joinable."""
try:
thread = await get_client().threads.get(thread_id)
except Exception:
return False
metadata = (
thread.get("metadata") if isinstance(thread, dict) else getattr(thread, "metadata", None)
)
return _thread_is_readable(metadata if isinstance(metadata, dict) else {})
async def _open_room(server: WebsocketServer, thread_id: str) -> None:
"""Seed the room from the store and start its snapshot flusher (idempotent
while connected)."""
if thread_id in _flushers:
return
room = await server.get_room(thread_id)
snapshot = await load_yjs_snapshot(thread_id)
if snapshot:
try:
room.ydoc.apply_update(snapshot)
except Exception:
logger.warning("Failed to seed plan doc %s from snapshot", thread_id, exc_info=True)
def _on_change(_event: object) -> None:
_dirty.add(thread_id)
_subscriptions[thread_id] = room.ydoc.observe(_on_change)
_flushers[thread_id] = asyncio.create_task(_flush_loop(thread_id, room))
async def _close_room(thread_id: str) -> None:
"""Tear down a room's flusher + observer once the last reviewer leaves."""
task = _flushers.pop(thread_id, None)
_subscriptions.pop(thread_id, None)
if task is not None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if _server is not None and thread_id in _server.rooms:
await _persist(thread_id, _server.rooms[thread_id])
_dirty.discard(thread_id)
async def _flush_loop(thread_id: str, room: YRoom) -> None:
while True:
await asyncio.sleep(_SNAPSHOT_DEBOUNCE_SECONDS)
if thread_id in _dirty:
_dirty.discard(thread_id)
await _persist(thread_id, room)
async def _persist(thread_id: str, room: YRoom) -> None:
try:
await save_yjs_snapshot(thread_id, bytes(room.ydoc.get_update()))
except Exception:
logger.warning("Failed to snapshot plan doc %s", thread_id, exc_info=True)
@collab_router.websocket("/yjs/{thread_id}")
async def plan_yjs_socket(websocket: WebSocket, thread_id: str) -> None:
if _session_login(websocket) is None:
await websocket.close(code=4401)
return
if not _origin_allowed(websocket):
await websocket.close(code=4403)
return
if not await _can_read_thread(thread_id):
await websocket.close(code=4403)
return
if _server is None:
await websocket.close(code=1013) # try again later
return
await websocket.accept()
await _open_room(_server, thread_id)
_connections[thread_id] = _connections.get(thread_id, 0) + 1
channel = HttpxWebsocket(websocket, thread_id)
try:
with contextlib.suppress(WebSocketDisconnect):
await _server.serve(channel)
finally:
_connections[thread_id] = _connections.get(thread_id, 1) - 1
if _connections.get(thread_id, 0) <= 0:
_connections.pop(thread_id, None)
await _close_room(thread_id)

View file

@ -0,0 +1,114 @@
"""Persistence for the collaborative plan-review feature.
The plan lives in two places:
- the agent's sandbox, as a real ``plan.md`` file (written by the ``save_plan``
tool — the source artifact the agent produces and can re-read), and
- the LangGraph store, as the published snapshot the dashboard renders and the
seed for the collaborative Yjs document.
Comment threads live in the Yjs document (BlockNote's ``YjsThreadStore``); the
binary Yjs state is snapshotted here so a plan + its comments survive a sandbox
teardown or a server restart.
"""
from __future__ import annotations
import base64
from typing import Any
from langgraph_sdk import get_client
PLAN_CONTENT_NAMESPACE = ["plan", "content"]
PLAN_YJS_NAMESPACE = ["plan", "yjs"]
# Plan lifecycle, stored on both the content record and the thread metadata.
PLAN_STATUS_PLANNING = "planning"
PLAN_STATUS_READY = "ready"
PLAN_STATUS_REVISING = "revising"
PLAN_STATUS_APPROVED = "approved"
PLAN_STATUS_CANCELLED = "cancelled"
def _client() -> Any:
return get_client()
def _item_value(item: Any) -> dict[str, Any] | None:
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
async def save_plan_content(
thread_id: str, *, markdown: str, status: str = PLAN_STATUS_READY
) -> None:
"""Publish the plan markdown + status for the dashboard to render."""
client = _client()
await client.store.put_item(
PLAN_CONTENT_NAMESPACE,
thread_id,
{"markdown": markdown, "status": status},
)
await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True})
async def get_plan_content(thread_id: str) -> dict[str, Any] | None:
client = _client()
try:
item = await client.store.get_item(PLAN_CONTENT_NAMESPACE, thread_id)
except Exception:
return None
return _item_value(item)
async def set_plan_status(thread_id: str, status: str, *, plan_mode: bool | None = None) -> None:
"""Update the plan lifecycle status on both the content record and metadata."""
existing = await get_plan_content(thread_id) or {}
client = _client()
await client.store.put_item(
PLAN_CONTENT_NAMESPACE,
thread_id,
{"markdown": existing.get("markdown", ""), "status": status},
)
metadata: dict[str, Any] = {"plan_status": status}
if plan_mode is not None:
metadata["plan_mode"] = plan_mode
await _merge_thread_metadata(thread_id, metadata)
async def load_yjs_snapshot(thread_id: str) -> bytes | None:
client = _client()
try:
item = await client.store.get_item(PLAN_YJS_NAMESPACE, thread_id)
except Exception:
return None
value = _item_value(item)
if not value:
return None
encoded = value.get("b64")
if not isinstance(encoded, str) or not encoded:
return None
try:
return base64.b64decode(encoded)
except (ValueError, TypeError):
return None
async def save_yjs_snapshot(thread_id: str, data: bytes) -> None:
client = _client()
await client.store.put_item(
PLAN_YJS_NAMESPACE,
thread_id,
{"b64": base64.b64encode(data).decode("ascii")},
)
async def _merge_thread_metadata(thread_id: str, metadata: dict[str, Any]) -> None:
client = _client()
try:
await client.threads.update(thread_id=thread_id, metadata=metadata)
except Exception:
# The thread always exists by the time a plan is saved (the run created
# it); a transient update failure must not crash the agent mid-run.
pass

View file

@ -114,6 +114,7 @@ class ThreadMessageBody(BaseModel):
images: list[DashboardImageBody] = Field(default_factory=list) images: list[DashboardImageBody] = Field(default_factory=list)
model_id: str | None = None model_id: str | None = None
effort: str | None = None effort: str | None = None
plan_mode: bool = False
class ThreadResolveBody(BaseModel): class ThreadResolveBody(BaseModel):
@ -358,6 +359,8 @@ def _thread_summary(
"branch": metadata.get("branch_name") or metadata.get("base_branch") or "main", "branch": metadata.get("branch_name") or metadata.get("base_branch") or "main",
"model": model, "model": model,
"effort": effort, "effort": effort,
"planMode": metadata.get("plan_mode") is True,
"planStatus": metadata.get("plan_status"),
"source": _thread_source(metadata), "source": _thread_source(metadata),
"status": status, "status": status,
"viewed": _is_thread_viewed(metadata, latest_run_id), "viewed": _is_thread_viewed(metadata, latest_run_id),
@ -913,6 +916,7 @@ async def _create_dashboard_thread_record(
title: str | None = None, title: str | None = None,
model_id: str | None = None, model_id: str | None = None,
effort: str | None = None, effort: str | None = None,
plan_mode: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create or update dashboard thread metadata without starting a run.""" """Create or update dashboard thread metadata without starting a run."""
profile = await get_profile(login) or {} profile = await get_profile(login) or {}
@ -937,6 +941,7 @@ async def _create_dashboard_thread_record(
"effort": metadata_effort, "effort": metadata_effort,
"resolved_model": resolved_model, "resolved_model": resolved_model,
"resolved_effort": resolved_effort, "resolved_effort": resolved_effort,
"plan_mode": plan_mode,
"created_at_ms": now_ms, "created_at_ms": now_ms,
"updated_at_ms": now_ms, "updated_at_ms": now_ms,
} }
@ -985,6 +990,8 @@ async def _build_dashboard_configurable(
if isinstance(source_context, dict): if isinstance(source_context, dict):
for key, value in source_context.items(): for key, value in source_context.items():
configurable.setdefault(key, value) configurable.setdefault(key, value)
if metadata.get("plan_mode") is True:
configurable["plan_mode"] = True
if overrides: if overrides:
for key, value in overrides.items(): for key, value in overrides.items():
if value is not None: if value is not None:
@ -1123,6 +1130,7 @@ async def _enrich_run_start_command(
client_configurable.get("agent_model_id"), client_configurable.get("agent_model_id"),
client_configurable.get("agent_effort"), client_configurable.get("agent_effort"),
) )
plan_mode_requested = client_configurable.get("plan_mode") is True
content = _command_message_content(params) content = _command_message_content(params)
overrides: dict[str, Any] = {} overrides: dict[str, Any] = {}
@ -1142,6 +1150,7 @@ async def _enrich_run_start_command(
images=_dashboard_images_from_content(content), images=_dashboard_images_from_content(content),
model_id=client_configurable.get("agent_model_id"), model_id=client_configurable.get("agent_model_id"),
effort=client_configurable.get("agent_effort"), effort=client_configurable.get("agent_effort"),
plan_mode=plan_mode_requested,
) )
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
if chosen_model and chosen_effort: if chosen_model and chosen_effort:
@ -1152,7 +1161,7 @@ async def _enrich_run_start_command(
prefix = _attribution_prefix(metadata, login, email) prefix = _attribution_prefix(metadata, login, email)
if prefix: if prefix:
_set_command_last_message_content(params, _prefix_message_content(content, prefix)) _set_command_last_message_content(params, _prefix_message_content(content, prefix))
metadata_update: dict[str, Any] = {} metadata_update: dict[str, Any] = {"plan_mode": plan_mode_requested}
if chosen_model and chosen_effort: if chosen_model and chosen_effort:
overrides["agent_model_id"] = chosen_model overrides["agent_model_id"] = chosen_model
overrides["agent_effort"] = chosen_effort overrides["agent_effort"] = chosen_effort
@ -1202,7 +1211,11 @@ async def send_dashboard_message(
prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}" prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}"
now_ms = _now_ms() now_ms = _now_ms()
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort) chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
metadata_update: dict[str, Any] = {"source": _DASHBOARD_SOURCE, "updated_at_ms": now_ms} metadata_update: dict[str, Any] = {
"source": _DASHBOARD_SOURCE,
"updated_at_ms": now_ms,
"plan_mode": body.plan_mode,
}
if chosen_model and chosen_effort: if chosen_model and chosen_effort:
metadata_update["model"] = chosen_model metadata_update["model"] = chosen_model
metadata_update["effort"] = chosen_effort metadata_update["effort"] = chosen_effort

View file

@ -2,6 +2,7 @@ from .check_message_queue import check_message_queue_before_model
from .exclude_tools import ExcludeToolsMiddleware from .exclude_tools import ExcludeToolsMiddleware
from .model_fallback import ModelFallbackMiddleware from .model_fallback import ModelFallbackMiddleware
from .notify_step_limit import notify_step_limit_reached from .notify_step_limit import notify_step_limit_reached
from .plan_mode import PlanModeMiddleware
from .refresh_github_proxy import refresh_github_proxy_before_model from .refresh_github_proxy import refresh_github_proxy_before_model
from .refresh_slack_status import SlackAssistantStatusMiddleware from .refresh_slack_status import SlackAssistantStatusMiddleware
from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware
@ -14,6 +15,7 @@ from .tool_error_handler import ToolErrorMiddleware
__all__ = [ __all__ = [
"ExcludeToolsMiddleware", "ExcludeToolsMiddleware",
"ModelFallbackMiddleware", "ModelFallbackMiddleware",
"PlanModeMiddleware",
"SanitizeThinkingBlocksMiddleware", "SanitizeThinkingBlocksMiddleware",
"SanitizeToolInputsMiddleware", "SanitizeToolInputsMiddleware",
"ToolArtifactMiddleware", "ToolArtifactMiddleware",

View file

@ -0,0 +1,83 @@
"""Plan-mode tool gating.
Hides the mutating tools whenever plan mode is active — either when the run
starts in plan mode (the per-thread ``plan_mode`` carried in configurable, e.g.
a reject re-dispatch) OR after the model calls ``enter_plan_mode`` mid-run, which
sets ``plan_mode`` in the run state. Installed unconditionally so self-activation
actually restricts the *next* model turn (the tool list is recomputed on every
model call), rather than only affecting a future run.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any, NotRequired
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ModelRequest,
ModelResponse,
)
from langchain_core.tools import BaseTool
class PlanModeState(AgentState):
# Declared so ``enter_plan_mode``'s Command update is a tracked channel that
# this middleware can read back from ``request.state``.
plan_mode: NotRequired[bool]
def _tool_name(tool: BaseTool | dict[str, Any] | Any) -> str | None:
if isinstance(tool, dict):
name = tool.get("name")
return name if isinstance(name, str) else None
name = getattr(tool, "name", None)
return name if isinstance(name, str) else None
class PlanModeMiddleware(AgentMiddleware):
"""Strip mutating tools from each model request while plan mode is active."""
state_schema = PlanModeState
def __init__(self, *, excluded: frozenset[str], initial: bool = False) -> None:
self._excluded = excluded
self._initial = initial
def before_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ARG002
# Reset plan_mode to the value resolved for THIS run so a stale ``True``
# left in the thread state by a previous run's ``enter_plan_mode`` does
# not silently force a later (e.g. approved/implementing) run back into
# plan mode. ``enter_plan_mode`` can still flip it on within this run.
return {"plan_mode": self._initial}
def _active(self, request: ModelRequest) -> bool:
if self._initial:
return True
state = getattr(request, "state", None)
if isinstance(state, dict):
return state.get("plan_mode") is True
return getattr(state, "plan_mode", None) is True
def _filter(self, request: ModelRequest) -> ModelRequest:
if not self._excluded or not self._active(request):
return request
filtered = [t for t in request.tools if _tool_name(t) not in self._excluded]
if len(filtered) == len(request.tools):
return request
return request.override(tools=filtered)
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
return handler(self._filter(request))
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
return await handler(self._filter(request))

View file

@ -69,6 +69,67 @@ You are currently executing a software engineering task. You have access to:
- Project-specific rules and conventions from the repository's `AGENTS.md` file (read after cloning — see Repository Setup)""" - Project-specific rules and conventions from the repository's `AGENTS.md` file (read after cloning — see Repository Setup)"""
PLAN_MODE_GUIDANCE_SECTION = """---
### Plan Mode
If you believe the task would benefit from a structured implementation plan before writing any code — e.g. when the request is complex, touches many files, or has multiple valid approaches — call the `enter_plan_mode` tool. This is NOT triggered by the word "plan" appearing in the request; use your judgment about whether planning is genuinely warranted. Once plan mode is active, stay read-only: research the code, then record your plan with the `save_plan` tool (it writes `plan.md` and publishes the plan to a review page) and share the plan-review link with the user. The user reviews and approves the plan before you implement.
Plan-review link for this conversation (share it with the user when you enter plan mode): {plan_review_url}"""
PLAN_MODE_SECTION = """---
### Plan Mode (ACTIVE)
**Plan mode is enabled for this run. This section supersedes any other instruction that tells you to edit code, commit, push, or open a pull request.**
You are in a read-only research-and-planning phase. Your single deliverable is a clear, reviewable implementation plan saved with the `save_plan` tool — NOT code changes. The user (and any collaborators) review the plan on the plan-review page, leave inline comments, and approve it (or request changes); only then do you implement.
**Plan-review link:** {plan_url}
Share this exact link with the user (via `slack_thread_reply` or `linear_comment`) right after you enter plan mode, so they know where to follow along, and again when the plan is ready for review.
**You MUST NOT:**
- Edit, create, or delete any files in the repository (no `write_file`, no `edit_file`).
- Run any state-changing command via `execute` — no `git commit`, `git push`, `git checkout -b`, package installs, code generators, formatters that rewrite files, or anything that mutates the filesystem, git state, or remote services. Keep `execute` to read-only commands only.
- Commit, push, open or update a pull request, or call `request_pr_review`.
- Create, update, or delete Linear issues, or otherwise mutate external systems.
**You MAY (read-only):**
- Clone the repo and read it: `read_file`, `ls`, `glob`, `grep`, and read-only `execute` commands (`git clone`, `git status`, `git log`, `git diff`, `cat`, `rg`, `ls`).
- Research the web with `web_search` / `fetch_url`.
- Ask the user clarifying questions via `slack_thread_reply` (Slack) or `linear_comment` (Linear) when the source channel is known.
(The `task` subagent tool is disabled in plan mode because subagents would not inherit these read-only restrictions. Do your research directly with the read-only tools above.)
**Workflow:**
1. **Explore** — Clone (if needed) and read the relevant code to understand existing patterns, the files involved, and constraints. Read aggressively; a good plan is grounded in the actual codebase, not assumptions.
2. **Clarify** — If the request is ambiguous or has multiple valid approaches, ask focused questions before finalizing the plan.
3. **Plan** — Write ONE recommended implementation plan and save it with the `save_plan` tool (pass the full Markdown as `plan_markdown`). Use this structure:
```
## Plan: <short title>
### Overview
<1-3 sentences on the approach and why.>
### Files to change
- `path/to/file` — <what changes and why>
- ...
### Steps
1. <ordered, concrete implementation steps>
2. ...
### Risks & considerations
- <edge cases, migrations, cross-file impacts, anything risky>
### Verification
- <how the change will be tested/validated: specific test files, lint, manual checks>
```
**Ending your turn:** After saving the plan with `save_plan`, post a brief completion message with the plan-review link via `slack_thread_reply` (Slack) or `linear_comment` (Linear), then stop. Explicitly invite the user to review the plan, comment, and approve it. Do not begin implementing — wait until the plan is approved (you will be re-invoked with the approval and any reviewer feedback)."""
SELF_AWARENESS_SECTION = """--- SELF_AWARENESS_SECTION = """---
### About You ### About You
@ -443,6 +504,8 @@ def _render_repo_instructions_section(instructions: str | None) -> str:
SYSTEM_PROMPT_TEMPLATE = ( SYSTEM_PROMPT_TEMPLATE = (
WORKING_ENV_SECTION WORKING_ENV_SECTION
+ TASK_OVERVIEW_SECTION + TASK_OVERVIEW_SECTION
+ PLAN_MODE_GUIDANCE_SECTION
+ "{plan_mode_section}"
+ SELF_AWARENESS_SECTION + SELF_AWARENESS_SECTION
+ "{default_prompt_section}" + "{default_prompt_section}"
+ REPO_SETUP_SECTION + REPO_SETUP_SECTION
@ -471,6 +534,8 @@ def construct_system_prompt(
triggering_user_identity: CollaboratorIdentity | None = None, triggering_user_identity: CollaboratorIdentity | None = None,
create_prs: bool = False, create_prs: bool = False,
default_repo: dict[str, str] | None = None, default_repo: dict[str, str] | None = None,
plan_mode: bool = False,
plan_url: str | None = None,
repo_custom_instructions: str | None = None, repo_custom_instructions: str | None = None,
thread_url: str | None = None, thread_url: str | None = None,
corridor_enabled: bool = False, corridor_enabled: bool = False,
@ -494,6 +559,12 @@ def construct_system_prompt(
working_dir=working_dir, working_dir=working_dir,
linear_project_id=linear_project_id or "<PROJECT_ID>", linear_project_id=linear_project_id or "<PROJECT_ID>",
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>", linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
plan_review_url=plan_url or "(the dashboard plan-review page)",
plan_mode_section=(
PLAN_MODE_SECTION.format(plan_url=plan_url or "(plan-review link unavailable)")
if plan_mode
else ""
),
default_prompt_section=default_prompt_section, default_prompt_section=default_prompt_section,
corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "", corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "",
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "", pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",

View file

@ -41,7 +41,10 @@ from .dashboard.agent_overrides import (
) )
from .dashboard.agent_usage import record_agent_thread_usage from .dashboard.agent_usage import record_agent_thread_usage
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo from .dashboard.team_settings import (
get_team_default_model_pair,
get_team_default_repo,
)
from .dashboard.user_mappings import email_for_login from .dashboard.user_mappings import email_for_login
from .integrations.corridor_mcp import load_corridor_tools from .integrations.corridor_mcp import load_corridor_tools
from .integrations.currents_tools import load_currents_tools from .integrations.currents_tools import load_currents_tools
@ -50,6 +53,7 @@ from .integrations.langsmith import _configure_github_proxy
from .integrations.langsmith_tools import load_langsmith_tools from .integrations.langsmith_tools import load_langsmith_tools
from .middleware import ( from .middleware import (
ModelFallbackMiddleware, ModelFallbackMiddleware,
PlanModeMiddleware,
SandboxCircuitBreakerMiddleware, SandboxCircuitBreakerMiddleware,
SanitizeThinkingBlocksMiddleware, SanitizeThinkingBlocksMiddleware,
SanitizeToolInputsMiddleware, SanitizeToolInputsMiddleware,
@ -62,6 +66,7 @@ from .middleware import (
) )
from .prompt import construct_system_prompt from .prompt import construct_system_prompt
from .tools import ( from .tools import (
enter_plan_mode,
fetch_url, fetch_url,
http_request, http_request,
linear_comment, linear_comment,
@ -73,6 +78,7 @@ from .tools import (
linear_update_issue, linear_update_issue,
open_pull_request, open_pull_request,
request_pr_review, request_pr_review,
save_plan,
schedule_thread_wakeup, schedule_thread_wakeup,
slack_read_thread_messages, slack_read_thread_messages,
slack_thread_reply, slack_thread_reply,
@ -84,7 +90,7 @@ from .utils.authorship import (
OPEN_SWE_BOT_NAME, OPEN_SWE_BOT_NAME,
resolve_triggering_user_identity, resolve_triggering_user_identity,
) )
from .utils.dashboard_links import dashboard_thread_url from .utils.dashboard_links import dashboard_plan_url, dashboard_thread_url
from .utils.github_app import ( from .utils.github_app import (
get_github_app_installation_token_with_expiry, get_github_app_installation_token_with_expiry,
) )
@ -477,6 +483,28 @@ DEFAULT_LLM_MAX_TOKENS = 64_000
DEFAULT_RECURSION_LIMIT = 9_999 DEFAULT_RECURSION_LIMIT = 9_999
MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls
# Mutating tools hidden from the model while plan mode is active so it can only
# research and propose a plan. `execute` stays available; plan-mode shell
# discipline (no mutating commands) is instructed via the system prompt rather
# than enforced. `http_request` is excluded because it can POST/PUT/PATCH/DELETE
# to external services — read-only web research goes through `web_search` /
# `fetch_url`. `task` is excluded because the general-purpose subagent is built
# with its own filesystem/PR/Linear tools and does not inherit this exclusion, so
# delegating to it would bypass the read-only intent.
PLAN_MODE_EXCLUDED_TOOLS: frozenset[str] = frozenset(
{
"write_file",
"edit_file",
"task",
"http_request",
"open_pull_request",
"request_pr_review",
"linear_create_issue",
"linear_update_issue",
"linear_delete_issue",
}
)
def _general_purpose_subagent(model: BaseChatModel) -> SubAgent: def _general_purpose_subagent(model: BaseChatModel) -> SubAgent:
return { return {
@ -662,6 +690,19 @@ async def get_agent(config: RunnableConfig) -> Pregel:
) )
logger.info("Configured model fallback %s -> %s", model_id, fallback_model_id) logger.info("Configured model fallback %s -> %s", model_id, fallback_model_id)
# Plan mode is entered only when the model decides to (the `enter_plan_mode`
# tool sets it in run state). The configurable value just carries that
# decision across a thread's messages and the approve/reject follow-ups; a
# fresh run with nothing set starts out of plan mode.
plan_mode = configurable.get("plan_mode") is True
if plan_mode:
logger.info("Plan mode enabled for thread %s", thread_id)
# Installed unconditionally and state-aware: it also restricts tools after a
# mid-run `enter_plan_mode` call, not just when plan mode is set up front.
plan_mode_middleware: list[Any] = [
PlanModeMiddleware(excluded=PLAN_MODE_EXCLUDED_TOOLS, initial=plan_mode)
]
source = ( source = (
configurable.get("source") if isinstance(configurable.get("source"), str) else "dashboard" configurable.get("source") if isinstance(configurable.get("source"), str) else "dashboard"
) )
@ -675,6 +716,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
"model": model_id, "model": model_id,
"effort": profile_effort, "effort": profile_effort,
"source": source, "source": source,
"plan_mode": plan_mode,
}, },
) )
await record_agent_thread_usage( await record_agent_thread_usage(
@ -716,6 +758,8 @@ async def get_agent(config: RunnableConfig) -> Pregel:
triggering_user_identity=triggering_user_identity, triggering_user_identity=triggering_user_identity,
create_prs=always_create_prs, create_prs=always_create_prs,
default_repo=prompt_default_repo, default_repo=prompt_default_repo,
plan_mode=plan_mode,
plan_url=dashboard_plan_url(thread_id),
repo_custom_instructions=repo_custom_instructions, repo_custom_instructions=repo_custom_instructions,
thread_url=dashboard_thread_url(thread_id), thread_url=dashboard_thread_url(thread_id),
corridor_enabled=bool(corridor_tools), corridor_enabled=bool(corridor_tools),
@ -724,6 +768,8 @@ async def get_agent(config: RunnableConfig) -> Pregel:
http_request, http_request,
fetch_url, fetch_url,
web_search, web_search,
enter_plan_mode,
save_plan,
linear_comment, linear_comment,
linear_create_issue, linear_create_issue,
linear_delete_issue, linear_delete_issue,
@ -753,6 +799,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
notify_step_limit_reached, notify_step_limit_reached,
SandboxCircuitBreakerMiddleware(), SandboxCircuitBreakerMiddleware(),
*fallback_middleware, *fallback_middleware,
*plan_mode_middleware,
SanitizeThinkingBlocksMiddleware(), SanitizeThinkingBlocksMiddleware(),
], ],
).with_config(config) ).with_config(config)

View file

@ -1,4 +1,5 @@
from .add_finding import add_finding from .add_finding import add_finding
from .enter_plan_mode import enter_plan_mode
from .fetch_url import fetch_url from .fetch_url import fetch_url
from .http_request import http_request from .http_request import http_request
from .linear_comment import linear_comment from .linear_comment import linear_comment
@ -16,6 +17,7 @@ from .read_repo_file import read_repo_file
from .reply_to_finding_thread import reply_to_finding_thread from .reply_to_finding_thread import reply_to_finding_thread
from .request_pr_review import request_pr_review from .request_pr_review import request_pr_review
from .resolve_finding_thread import resolve_finding_thread from .resolve_finding_thread import resolve_finding_thread
from .save_plan import save_plan
from .schedule_thread_wakeup import schedule_thread_wakeup from .schedule_thread_wakeup import schedule_thread_wakeup
from .search_repo_code import search_repo_code from .search_repo_code import search_repo_code
from .slack_read_thread_messages import slack_read_thread_messages from .slack_read_thread_messages import slack_read_thread_messages
@ -25,6 +27,7 @@ from .web_search import web_search
__all__ = [ __all__ = [
"add_finding", "add_finding",
"enter_plan_mode",
"fetch_url", "fetch_url",
"http_request", "http_request",
"linear_comment", "linear_comment",
@ -42,6 +45,7 @@ __all__ = [
"request_pr_review", "request_pr_review",
"reply_to_finding_thread", "reply_to_finding_thread",
"resolve_finding_thread", "resolve_finding_thread",
"save_plan",
"schedule_thread_wakeup", "schedule_thread_wakeup",
"search_repo_code", "search_repo_code",
"slack_read_thread_messages", "slack_read_thread_messages",

View file

@ -0,0 +1,62 @@
"""Tool: ``enter_plan_mode``. Switch the run into read-only planning."""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from langchain_core.messages import ToolMessage
from langchain_core.tools import InjectedToolCallId
from langgraph.config import get_config
from langgraph.types import Command
from ..dashboard.plan_store import PLAN_STATUS_PLANNING, set_plan_status
logger = logging.getLogger(__name__)
_ENTERED_MESSAGE = (
"Plan mode is active. Stay read-only: research the codebase, then record your "
"implementation plan with the `save_plan` tool (it publishes the plan to the "
"review page) and share the plan-review link in the source channel. Do not edit "
"files, commit, push, or open a PR — wait for the user to approve the plan."
)
def enter_plan_mode(tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Activate plan mode mid-run.
Call this when you believe the task would benefit from a structured
implementation plan before writing any code — e.g. when the request is
complex, touches many files, or has multiple valid approaches. This is
NOT triggered by the word "plan" appearing in the request; use your
judgment about whether planning is genuinely warranted.
Once activated, stay read-only: research the codebase, then record your plan
with the ``save_plan`` tool (it publishes the plan to the review page) and
share the plan-review link with the user. Do not edit files, commit, push,
or open a PR — the user reviews the plan and approves it before you
implement.
"""
thread_id = _thread_id_from_config()
if thread_id:
try:
asyncio.run(set_plan_status(thread_id, PLAN_STATUS_PLANNING, plan_mode=True))
except Exception:
logger.warning("Failed to persist plan-mode entry for %s", thread_id, exc_info=True)
return Command(
update={
"plan_mode": True,
"messages": [ToolMessage(content=_ENTERED_MESSAGE, tool_call_id=tool_call_id)],
}
)
def _thread_id_from_config() -> str | None:
try:
config = get_config()
except Exception:
return None
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
return str(thread_id) if thread_id else None

81
agent/tools/save_plan.py Normal file
View file

@ -0,0 +1,81 @@
"""Tool: ``save_plan``. Record the implementation plan for review.
Writes the plan as a real ``plan.md`` file in the sandbox (the artifact the
agent produces and can re-read) and publishes it to the plan-review page, where
the user and collaborators read it, comment inline, and approve or request
changes. Available in plan mode (it does not modify the repository under review).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from langgraph.config import get_config
from ..dashboard.plan_store import PLAN_STATUS_READY, save_plan_content
logger = logging.getLogger(__name__)
PLAN_FILE_PATH = "plan.md"
def save_plan(plan_markdown: str) -> dict[str, Any]:
"""Write your implementation plan as a markdown file and publish it for review.
Use this in plan mode once your plan is ready. The plan is saved as
``plan.md`` in the sandbox and published to the plan-review page linked in
the conversation, where the user (the owner) and any collaborators can read
it, leave inline comments, and then approve it or request changes. Call it
again to overwrite the plan with a revised version when addressing feedback.
Write the plan in standard Markdown — headings, bullet/numbered lists, and
fenced code blocks all render. Structure it clearly (overview, files to
change, ordered steps, risks).
Args:
plan_markdown: The full plan, as a Markdown document.
Returns:
``{success: True, path}`` on success, or ``{success: False, error}``.
"""
content = plan_markdown.strip()
if not content:
return {"success": False, "error": "plan_markdown cannot be empty"}
try:
config = get_config()
except Exception:
config = {}
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
if not thread_id:
return {"success": False, "error": "no thread_id in run config"}
try:
path = asyncio.run(_save(str(thread_id), content))
except Exception as exc: # noqa: BLE001
logger.exception("save_plan failed for thread %s", thread_id)
return {"success": False, "error": f"failed to save plan: {exc}"}
return {"success": True, "path": path}
async def _save(thread_id: str, content: str) -> str:
sandbox_path = await _write_to_sandbox(thread_id, content)
await save_plan_content(thread_id, markdown=content, status=PLAN_STATUS_READY)
return sandbox_path
async def _write_to_sandbox(thread_id: str, content: str) -> str:
"""Write ``plan.md`` into the thread's sandbox. Best-effort: a missing sandbox
must not block publishing the plan to the review page."""
try:
from ..utils.sandbox_state import get_sandbox_backend
backend = await get_sandbox_backend(thread_id)
await backend.awrite(PLAN_FILE_PATH, content)
return PLAN_FILE_PATH
except Exception:
logger.warning("Could not write plan.md to sandbox for %s", thread_id, exc_info=True)
return PLAN_FILE_PATH

View file

@ -21,6 +21,7 @@ def slack_thread_reply(
message: str, message: str,
options: list[str] | None = None, options: list[str] | None = None,
blocks: list[dict[str, Any]] | None = None, blocks: list[dict[str, Any]] | None = None,
plan_approval: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Post a message to the current Slack thread. """Post a message to the current Slack thread.
@ -39,6 +40,8 @@ def slack_thread_reply(
render interactive buttons and the web UI will render the same choices. render interactive buttons and the web UI will render the same choices.
The user can still reply manually in the Slack thread. The user can still reply manually in the Slack thread.
To present a plan for approval with action buttons, pass plan_approval=True. This renders "Approve & Implement", "Revise Plan", and "Cancel" buttons. The plan itself should be posted to the dashboard thread; use this to post a summary with a link to the dashboard thread view where the full plan can be reviewed.
To mention/tag a user, use Slack's mention format: <@USER_ID>. To mention/tag a user, use Slack's mention format: <@USER_ID>.
You can find user IDs in the conversation context (e.g. @Name(U06KD8BFY95)). You can find user IDs in the conversation context (e.g. @Name(U06KD8BFY95)).
Example: <@U06KD8BFY95> will tag that user in the message.""" Example: <@U06KD8BFY95> will tag that user in the message."""
@ -58,7 +61,10 @@ def slack_thread_reply(
return {"success": False, "error": "Message cannot be empty"} return {"success": False, "error": "Message cannot be empty"}
message = convert_mentions_to_slack_format(message) message = convert_mentions_to_slack_format(message)
slack_blocks = blocks or _build_option_blocks(message, options) if plan_approval:
slack_blocks = _build_plan_approval_blocks(message)
else:
slack_blocks = blocks or _build_option_blocks(message, options)
message_ts, slack_error = asyncio.run( message_ts, slack_error = asyncio.run(
_post_and_store_mapping(channel_id, thread_ts, message, blocks=slack_blocks) _post_and_store_mapping(channel_id, thread_ts, message, blocks=slack_blocks)
) )
@ -96,6 +102,37 @@ def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[s
] ]
def _build_plan_approval_blocks(message: str) -> list[dict[str, Any]]:
return [
{"type": "section", "text": {"type": "mrkdwn", "text": message}},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve & Implement", "emoji": True},
"style": "primary",
"value": json.dumps({"type": "plan_approval", "action": "approve"}),
"action_id": "open_swe_option_select",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Revise Plan", "emoji": True},
"value": json.dumps({"type": "plan_approval", "action": "revise"}),
"action_id": "open_swe_option_select",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Cancel", "emoji": True},
"style": "danger",
"value": json.dumps({"type": "plan_approval", "action": "cancel"}),
"action_id": "open_swe_option_select",
},
],
},
]
def _slack_reply_failure_hint(slack_error: str | None) -> str: def _slack_reply_failure_hint(slack_error: str | None) -> str:
if slack_error == "msg_too_long": if slack_error == "msg_too_long":
return "Slack rejected the message as too long; retry with a shorter message." return "Slack rejected the message as too long; retry with a shorter message."

View file

@ -18,6 +18,14 @@ def dashboard_thread_url(thread_id: str) -> str | None:
return f"{base_url}/agents/{quote(thread_id, safe='')}" return f"{base_url}/agents/{quote(thread_id, safe='')}"
def dashboard_plan_url(thread_id: str) -> str | None:
"""Build the dashboard plan-review URL for a given thread id."""
base_url = _dashboard_base_url()
if not base_url or not thread_id:
return None
return f"{base_url}/agents/{quote(thread_id, safe='')}/plan"
def dashboard_review_url(owner: str, repo: str, pr_number: int) -> str | None: def dashboard_review_url(owner: str, repo: str, pr_number: int) -> str | None:
"""Build the dashboard review-detail URL for a PR.""" """Build the dashboard review-detail URL for a PR."""
base_url = _dashboard_base_url() base_url = _dashboard_base_url()

View file

@ -33,54 +33,50 @@ def _build_prod_langsmith_client() -> LangSmithClient | None:
def _resolve_project_id_by_name(project_name: str) -> str | None: def _resolve_project_id_by_name(project_name: str) -> str | None:
"""Resolve a LangSmith project id from its name, caching successful lookups.""" """Resolve a LangSmith project id from its name, caching both successful and
cached = _PROJECT_ID_CACHE.get(project_name) failed lookups so an unconfigured/unauthorized tenant isn't re-queried per call."""
if cached: if project_name in _PROJECT_ID_CACHE:
return cached return _PROJECT_ID_CACHE[project_name] or None
client = _build_prod_langsmith_client() client = _build_prod_langsmith_client()
if client is None: if client is None:
return None return None
try: try:
project = client.read_project(project_name=project_name) project = client.read_project(project_name=project_name)
except LangSmithNotFoundError: except LangSmithNotFoundError:
_PROJECT_ID_CACHE[project_name] = ""
return None return None
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("Failed to resolve LangSmith project id for %s", project_name, exc_info=True) logger.debug("Could not resolve LangSmith project id for %s", project_name)
_PROJECT_ID_CACHE[project_name] = ""
return None return None
project_id = getattr(project, "id", None) project_id = getattr(project, "id", None)
if not project_id: resolved = str(project_id) if project_id else ""
return None
resolved = str(project_id)
_PROJECT_ID_CACHE[project_name] = resolved _PROJECT_ID_CACHE[project_name] = resolved
return resolved return resolved or None
def _compose_langsmith_project_url(project_name: str = AGENT_TRACING_PROJECT) -> str: def _compose_langsmith_project_url(project_name: str = AGENT_TRACING_PROJECT) -> str | None:
"""Build the LangSmith project URL base for a given tracing project name.""" """Build the LangSmith project URL base, or None when tracing isn't configured
host_url = os.environ.get("LANGSMITH_URL_PROD", "https://smith.langchain.com") for the prod tenant. Bails before any API call when the tenant id is unset."""
tenant_id = os.environ.get("LANGSMITH_TENANT_ID_PROD") tenant_id = os.environ.get("LANGSMITH_TENANT_ID_PROD")
if not tenant_id:
return None
host_url = os.environ.get("LANGSMITH_URL_PROD", "https://smith.langchain.com")
project_id = _resolve_project_id_by_name(project_name) or os.environ.get( project_id = _resolve_project_id_by_name(project_name) or os.environ.get(
"LANGSMITH_TRACING_PROJECT_ID_PROD" "LANGSMITH_TRACING_PROJECT_ID_PROD"
) )
if not tenant_id or not project_id: if not project_id:
raise ValueError( return None
"LANGSMITH_TENANT_ID_PROD must be set and the tracing project id must be resolvable"
)
return f"{host_url}/o/{tenant_id}/projects/p/{project_id}" return f"{host_url}/o/{tenant_id}/projects/p/{project_id}"
def get_langsmith_trace_url( def get_langsmith_trace_url(
thread_id: str, project_name: str = AGENT_TRACING_PROJECT thread_id: str, project_name: str = AGENT_TRACING_PROJECT
) -> str | None: ) -> str | None:
"""Build the LangSmith thread URL for a given thread ID.""" """Build the LangSmith thread URL for a given thread ID, or None if tracing
try: isn't configured. This is a best-effort convenience link, not an error path."""
project_url = _compose_langsmith_project_url(project_name) project_url = _compose_langsmith_project_url(project_name)
return f"{project_url}/t/{thread_id}" return f"{project_url}/t/{thread_id}" if project_url else None
except Exception: # noqa: BLE001
logger.warning(
"Failed to build LangSmith trace URL for thread %s", thread_id, exc_info=True
)
return None
def _build_langsmith_feedback_clients() -> tuple[LangSmithClient, ...]: def _build_langsmith_feedback_clients() -> tuple[LangSmithClient, ...]:

View file

@ -131,12 +131,14 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]: async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
from .dashboard.plan_collab import collab_lifespan
from .utils.model import validate_local_dev_llm_config from .utils.model import validate_local_dev_llm_config
from .utils.sandbox import validate_sandbox_startup_config from .utils.sandbox import validate_sandbox_startup_config
validate_sandbox_startup_config() validate_sandbox_startup_config()
validate_local_dev_llm_config() validate_local_dev_llm_config()
yield async with collab_lifespan():
yield
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@ -159,6 +161,12 @@ if DASHBOARD_ALLOWED_ORIGINS:
app.include_router(dashboard_router) app.include_router(dashboard_router)
from .dashboard.plan_api import plan_router # noqa: E402
from .dashboard.plan_collab import collab_router # noqa: E402
app.include_router(plan_router)
app.include_router(collab_router)
LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "") LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "")
GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "") SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
@ -691,6 +699,68 @@ async def _ensure_thread_exists_for_metadata(
return False return False
async def _slack_user_is_thread_owner(thread_id: str, slack_user_id: str) -> bool:
"""Whether the clicking Slack user is the user who requested the plan.
Plan approval is owner-only (mirrors the dashboard plan API's
``_user_owns_thread`` gate). The original requester's Slack id is stored in
``source_context.slack_thread.triggering_user_id`` when the run is created.
Fails closed when ownership can't be determined.
"""
if not slack_user_id:
return False
langgraph_client = get_client(url=LANGGRAPH_URL)
try:
thread = await langgraph_client.threads.get(thread_id)
except Exception: # noqa: BLE001
return False
metadata = thread.get("metadata") if isinstance(thread, dict) else None
if not isinstance(metadata, dict):
return False
source_context = metadata.get("source_context")
slack_thread = source_context.get("slack_thread") if isinstance(source_context, dict) else None
owner_id = slack_thread.get("triggering_user_id") if isinstance(slack_thread, dict) else None
return isinstance(owner_id, str) and bool(owner_id) and owner_id == slack_user_id
async def _get_thread_plan_mode(thread_id: str) -> bool | None:
"""Return the persisted plan-mode flag for a thread, or ``None`` if unset."""
langgraph_client = get_client(url=LANGGRAPH_URL)
try:
thread = await langgraph_client.threads.get(thread_id)
except Exception as exc: # noqa: BLE001
if _is_not_found_error(exc):
return None
logger.warning("Failed to fetch plan-mode metadata for thread %s", thread_id)
return None
metadata = thread.get("metadata") if isinstance(thread, dict) else None
if not isinstance(metadata, dict):
return None
value = metadata.get("plan_mode")
return value if isinstance(value, bool) else None
async def _set_thread_plan_mode(thread_id: str, enabled: bool) -> None:
"""Persist the plan-mode flag onto thread metadata."""
langgraph_client = get_client(url=LANGGRAPH_URL)
try:
await langgraph_client.threads.update(
thread_id=thread_id, metadata={"plan_mode": bool(enabled)}
)
except Exception as exc: # noqa: BLE001
if _is_not_found_error(exc):
try:
await langgraph_client.threads.create(
thread_id=thread_id,
if_exists="do_nothing",
metadata={"plan_mode": bool(enabled)},
)
except Exception: # noqa: BLE001
logger.exception("Failed to create thread %s while persisting plan_mode", thread_id)
return
logger.exception("Failed to persist plan_mode for thread %s", thread_id)
async def process_linear_issue( # noqa: PLR0912, PLR0915 async def process_linear_issue( # noqa: PLR0912, PLR0915
issue_data: dict[str, Any], repo_config: dict[str, str] issue_data: dict[str, Any], repo_config: dict[str, str]
) -> None: ) -> None:
@ -1173,6 +1243,10 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
if mapped_login: if mapped_login:
configurable["github_login"] = mapped_login configurable["github_login"] = mapped_login
thread_plan_mode = await _get_thread_plan_mode(thread_id)
if thread_plan_mode is not None:
configurable["plan_mode"] = thread_plan_mode
langgraph_client = get_client(url=LANGGRAPH_URL) langgraph_client = get_client(url=LANGGRAPH_URL)
is_first_mention = not await _thread_exists(thread_id) is_first_mention = not await _thread_exists(thread_id)
await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client) await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
@ -1566,6 +1640,56 @@ async def slack_interactivity(
action_value = json.loads(str(action.get("value") or "{}")) action_value = json.loads(str(action.get("value") or "{}"))
except json.JSONDecodeError: except json.JSONDecodeError:
return {"status": "ignored", "reason": "Invalid action value"} return {"status": "ignored", "reason": "Invalid action value"}
if action_value.get("type") == "plan_approval":
plan_action = str(action_value.get("action") or "").strip()
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
channel_id = str(channel.get("id") or container.get("channel_id") or "")
thread_ts = str(
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or ""
)
user_id = str(user.get("id") or "")
if not channel_id or not thread_ts:
return {"status": "ignored", "reason": "Missing Slack action context"}
thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts)
if plan_action == "cancel":
await post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text="Plan cancelled. No changes will be made.",
)
return {"status": "accepted", "message": "Plan cancelled"}
if plan_action == "approve":
if not await _slack_user_is_thread_owner(thread_id, user_id):
await post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text="Only the person who requested this plan can approve it. Anyone can reply with feedback or use *Revise Plan*.",
)
return {"status": "ignored", "reason": "approver is not the thread owner"}
await _set_thread_plan_mode(thread_id, False)
repo_config = await get_slack_repo_config(channel_id, thread_ts, slack_user_id=user_id)
background_tasks.add_task(
process_slack_mention,
{
"channel_id": channel_id,
"thread_ts": thread_ts,
"event_ts": str(message.get("ts") or ""),
"user_id": user_id,
"text": "Proceed with the approved plan. Implement the changes as described in the plan.",
"bot_user_id": SLACK_BOT_USER_ID,
},
repo_config,
)
return {"status": "accepted", "message": "Plan approved, starting implementation"}
return {"status": "accepted", "message": "Reply to revise the plan"}
if action_value.get("type") != "open_swe_option": if action_value.get("type") != "open_swe_option":
return {"status": "ignored", "reason": "Unknown action type"} return {"status": "ignored", "reason": "Unknown action type"}

7
package.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "open-swe",
"private": true,
"scripts": {
"dev:mock": "bash tests/e2e/dev-mock.sh"
}
}

View file

@ -29,6 +29,8 @@ dependencies = [
"exa-py>=2.13.0", "exa-py>=2.13.0",
"langchain-google-genai>=4.2.4", "langchain-google-genai>=4.2.4",
"langchain-mcp-adapters>=0.2.2", "langchain-mcp-adapters>=0.2.2",
"pycrdt>=0.14.1,<0.15",
"pycrdt-websocket>=0.16.3,<0.17",
] ]
[project.optional-dependencies] [project.optional-dependencies]

63
tests/e2e/dev-mock.sh Normal file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Local dev against the mock harness, with a REAL LLM and a Vite HMR dev server.
# The browser uses a SINGLE origin ($UI, the Vite HMR server): your ui/src edits
# hot-reload, and Vite proxies the API + Yjs collaboration WebSocket + mock
# Slack/GitHub + sign-in to the harness ($API). Same-origin is what lets the
# session cookie ride the plan-review WebSocket (cross-origin WS drops it).
# The LLM is real; only the Slack/GitHub SaaS boundaries are faked.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
API_PORT="${E2E_PORT:-2024}"
UI_PORT="${UI_PORT:-3000}"
API="http://127.0.0.1:${API_PORT}"
UI="http://127.0.0.1:${UI_PORT}"
# Use a real OpenAI key. The agent talks to OpenAI directly, so a LangSmith
# gateway key (lsv2_*) stray in the shell won't authenticate — prefer .env's
# OPENAI_API_KEY unless the shell already holds an OpenAI-looking key (sk-…).
# Pulled line-by-line, not by sourcing .env, which would clobber the harness's
# test env (SANDBOX_TYPE, DASHBOARD_*, …).
case "${OPENAI_API_KEY:-}" in
sk-*) : ;;
*)
if [ -f .env ]; then
export OPENAI_API_KEY="$(grep -E '^OPENAI_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d "\"'")"
fi
;;
esac
case "${OPENAI_API_KEY:-}" in
sk-*) : ;;
*) echo "WARNING: OPENAI_API_KEY is not an OpenAI key (sk-…); model calls will 401." >&2 ;;
esac
# Single origin = the Vite HMR server ($UI). Agent links, the sign-in redirect,
# and the same-origin/WS allowlist all point there; the UI calls the API on its
# own origin and Vite proxies it (and the WS) to the harness.
export DASHBOARD_BASE_URL="$UI"
export DASHBOARD_ALLOWED_ORIGINS="$UI"
[ -d ui/node_modules ] || (cd ui && bun install)
echo
echo "┌──────────────────────────────────────────────────────────────────────┐"
echo "│ START HERE → ${UI}/login │"
echo "│ Continue with GitHub → pick Alice or Bob (mock sign-in) │"
echo "└──────────────────────────────────────────────────────────────────────┘"
echo " Mock Slack: ${UI}/mock/slack (send as Alice or Bob)"
echo " Everything is on ${UI} (Vite HMR; API + WS proxied to the harness)."
echo " LLM is real; Slack/GitHub are mocked. Open two browser profiles to be"
echo " two users (Alice owns the plan; Bob can comment + request changes)."
echo
E2E_REAL_LLM=1 uv run langgraph dev --config tests/e2e/langgraph.e2e.json \
--port "${API_PORT}" --no-browser --allow-blocking --no-reload &
HARNESS=$!
trap 'kill "${HARNESS}" 2>/dev/null || true' EXIT INT TERM
# E2E_HARNESS activates the dev-only proxy plugin in ui/vite.config.ts; an empty
# API base means the UI calls its own origin (proxied), keeping it same-origin.
cd ui
exec env E2E_HARNESS="${API}" VITE_DASHBOARD_API_BASE_URL="" \
./node_modules/.bin/vite dev --host 127.0.0.1 --port "${UI_PORT}"

View file

@ -67,11 +67,20 @@ _DEFAULTS = {
"DASHBOARD_JWT_SECRET": "test-dashboard-jwt-secret", "DASHBOARD_JWT_SECRET": "test-dashboard-jwt-secret",
} }
# The Slack run's triggering user (fake users.info returns this email) — the # Named test users (the Slack sender dropdown + the dashboard login picker, and
# thread owner. A session with this email may continue the thread on the web. # the identities the automated tests log in as). Each maps a Slack sender id to
SAME_USER = {"login": "dev-user", "email": "dev@example.com"} # a dashboard login with a matching email, so the Slack thread's owner (resolved
# Any other logged-in user: can view the thread read-only, cannot post. # by email) is the same person when they sign in. The first (Alice) is the
OTHER_USER = {"login": "someone-else", "email": "someone@example.com"} # default Slack sender, hence the default thread owner.
TEST_USERS = [
{"name": "Alice", "slack_id": "U_ALICE", "login": "alice", "email": "alice@example.com"},
{"name": "Bob", "slack_id": "U_BOB", "login": "bob", "email": "bob@example.com"},
]
# The default Slack sender / thread owner; a session with this email may continue
# the thread on the web. Any other logged-in user is read-only.
SAME_USER = {"login": TEST_USERS[0]["login"], "email": TEST_USERS[0]["email"]}
OTHER_USER = {"login": TEST_USERS[1]["login"], "email": TEST_USERS[1]["email"]}
for _k, _v in _DEFAULTS.items(): for _k, _v in _DEFAULTS.items():
os.environ.setdefault(_k, _v) os.environ.setdefault(_k, _v)

View file

@ -46,6 +46,19 @@ echo PUSHED_OK
""".strip() """.strip()
_PLAN_URL_RE = re.compile(r"https?://[^\s\"'<>)\]|]+/plan\b")
def _text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
)
return str(content)
def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None: def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None:
for msg in reversed(messages): for msg in reversed(messages):
if isinstance(msg, ToolMessage): if isinstance(msg, ToolMessage):
@ -56,6 +69,28 @@ def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None:
return None return None
def _plan_url_from_messages(messages: list[BaseMessage]) -> str | None:
"""The plan-review URL is injected into the system prompt; a real model would
read it the same way."""
for msg in messages:
match = _PLAN_URL_RE.search(_text(msg.content))
if match:
return match.group(0).rstrip(".,")
return None
def _reviewer_feedback(messages: list[BaseMessage]) -> str | None:
"""The harvested reviewer comments the backend hands the agent on approval."""
humans = [m for m in messages if isinstance(m, HumanMessage)]
if not humans:
return None
text = _text(humans[-1].content)
idx = text.lower().find("feedback")
if "approved" in text.lower() and idx != -1:
return text[idx:].strip()
return None
def _step_implement(_messages: list[BaseMessage]) -> AIMessage: def _step_implement(_messages: list[BaseMessage]) -> AIMessage:
return AIMessage( return AIMessage(
content="Setting up the repo and implementing the change.", content="Setting up the repo and implementing the change.",
@ -86,9 +121,11 @@ def _step_open_pr(_messages: list[BaseMessage]) -> AIMessage:
def _step_reply(messages: list[BaseMessage]) -> AIMessage: def _step_reply(messages: list[BaseMessage]) -> AIMessage:
url = _pr_url_from_messages(messages) or "(PR url unavailable)" url = _pr_url_from_messages(messages) or "(PR url unavailable)"
feedback = _reviewer_feedback(messages)
extra = f"\n\nReviewer feedback I addressed:\n{feedback}" if feedback else ""
text = ( text = (
f"✅ Done! I implemented the change and opened a PR: <{url}|{PR_TITLE}>\n\n" f"✅ Done! I implemented the change and opened a PR: <{url}|{PR_TITLE}>\n\n"
f"• Added `{FEATURE_FILE}` with a `greet()` helper.\n" f"• Added `{FEATURE_FILE}` with a `greet()` helper.{extra}\n"
"Let me know if you'd like any changes." "Let me know if you'd like any changes."
) )
return AIMessage( return AIMessage(
@ -97,6 +134,97 @@ def _step_reply(messages: list[BaseMessage]) -> AIMessage:
) )
# --- plan-mode flow --------------------------------------------------------
PLAN_MARKDOWN = """## Plan: Add greet() helper
### Overview
Add a tiny greeting helper to the demo repo.
### Files to change
- `greet.py` — new module exposing a `greet(name)` function.
### Steps
1. Create `greet.py` with a `greet(name)` function.
2. Open a draft PR with the change.
### Verification
- Import `greet` and confirm it returns the expected string.
"""
def _step_enter_plan(_messages: list[BaseMessage]) -> AIMessage:
return AIMessage(
content="This is worth planning first — entering plan mode.",
tool_calls=[{"name": "enter_plan_mode", "args": {}, "id": "call-enter-plan"}],
)
def _step_plan_link(messages: list[BaseMessage]) -> AIMessage:
url = _plan_url_from_messages(messages) or "(plan link unavailable)"
return AIMessage(
content="Sharing the plan-review link.",
tool_calls=[
{
"name": "slack_thread_reply",
"args": {
"message": f"I'm putting together a plan. Follow along and review it here: <{url}|plan review>"
},
"id": "call-plan-link",
}
],
)
def _step_plan_research(_messages: list[BaseMessage]) -> AIMessage:
return AIMessage(
content="Reading the repo to ground the plan.",
tool_calls=[
{"name": "execute", "args": {"command": "echo planning && ls"}, "id": "call-plan-read"}
],
)
def _step_save_plan(_messages: list[BaseMessage]) -> AIMessage:
return AIMessage(
content="Saving the plan for review.",
tool_calls=[
{"name": "save_plan", "args": {"plan_markdown": PLAN_MARKDOWN}, "id": "call-save-plan"}
],
)
def _step_plan_complete(messages: list[BaseMessage]) -> AIMessage:
url = _plan_url_from_messages(messages) or "(plan link unavailable)"
return AIMessage(
content="Announcing the plan is ready.",
tool_calls=[
{
"name": "slack_thread_reply",
"args": {
"message": f"✅ The plan is ready for review: <{url}|open the plan>. "
"Take a look, leave comments, and approve it when you're happy."
},
"id": "call-plan-done",
}
],
)
def _step_plan_end(_messages: list[BaseMessage]) -> AIMessage:
return AIMessage(content="I'll wait for your review and approval before implementing.")
def build_plan_script() -> list[Any]:
return [
_step_enter_plan,
_step_plan_link,
_step_plan_research,
_step_save_plan,
_step_plan_complete,
_step_plan_end,
]
FOLLOW_UP_REPLY = "Thanks! The PR is ready for review — anything else you'd like changed?" FOLLOW_UP_REPLY = "Thanks! The PR is ready for review — anything else you'd like changed?"
@ -133,13 +261,23 @@ class FakeScriptedChatModel(BaseChatModel):
run_manager: CallbackManagerForLLMRun | None = None, # noqa: ARG002 run_manager: CallbackManagerForLLMRun | None = None, # noqa: ARG002
**kwargs: Any, **kwargs: Any,
) -> ChatResult: ) -> ChatResult:
# First human turn implements + opens the PR; later turns (a web/Slack humans = [m for m in messages if isinstance(m, HumanMessage)]
# follow-up on the same thread) just reply. first_text = _text(humans[0].content) if humans else ""
human_turns = sum(1 for m in messages if isinstance(m, HumanMessage)) last_text = _text(humans[-1].content) if humans else ""
script = build_script() if human_turns <= 1 else build_followup_script()
# Pick the script for the current turn by what the latest human asked.
if _is_approval(last_text):
script = build_script() # implement + open PR + reply
elif _is_revision(last_text):
script = build_plan_script() # re-plan after requested changes
elif _is_plan_request(first_text) and len(humans) <= 1:
script = build_plan_script() # first ask was to plan
elif len(humans) <= 1:
script = build_script()
else:
script = build_followup_script()
# Step within the *current* turn: AIMessages since the last human turn. # Step within the *current* turn: AIMessages since the last human turn.
# (Counting the whole thread would short-circuit reused/multi-turn threads.)
last_human = max( last_human = max(
(i for i, m in enumerate(messages) if isinstance(m, HumanMessage)), default=-1 (i for i, m in enumerate(messages) if isinstance(m, HumanMessage)), default=-1
) )
@ -147,5 +285,19 @@ class FakeScriptedChatModel(BaseChatModel):
if step < len(script): if step < len(script):
message = script[step](messages) message = script[step](messages)
else: else:
message = AIMessage(content="All set — the PR is open and linked in the thread.") message = AIMessage(content="All set — let me know if you'd like anything else.")
return ChatResult(generations=[ChatGeneration(message=message)]) return ChatResult(generations=[ChatGeneration(message=message)])
def _is_plan_request(text: str) -> bool:
return "plan" in text.lower()
def _is_approval(text: str) -> bool:
t = text.lower()
return "approved" in t and "implement" in t
def _is_revision(text: str) -> bool:
t = text.lower()
return "needs changes" in t or "publish an updated plan" in t

View file

@ -36,9 +36,26 @@ from e2e_env import ( # noqa: E402
DEMO_CHANNEL, DEMO_CHANNEL,
HUMAN_USER, HUMAN_USER,
REPO_ROOT, REPO_ROOT,
TEST_USERS,
) )
from fastapi import HTTPException, Request # noqa: E402 from fastapi import HTTPException, Request # noqa: E402
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse # noqa: E402 from fastapi.responses import ( # noqa: E402
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
Response,
)
# Slack-user directory the fake ``users.info`` resolves: the default sender used
# by the automated tests plus the named manual-test users.
_SLACK_USERS: dict[str, dict[str, str]] = {
HUMAN_USER: {"name": "devuser", "real_name": "Dev User", "email": "dev@example.com"},
**{
u["slack_id"]: {"name": u["login"], "real_name": u["name"], "email": u["email"]}
for u in TEST_USERS
},
}
from agent.dashboard.oauth import COOKIE_NAME, issue_session # noqa: E402 from agent.dashboard.oauth import COOKIE_NAME, issue_session # noqa: E402
from agent.webapp import app, generate_thread_id_from_slack_thread # noqa: E402 from agent.webapp import app, generate_thread_id_from_slack_thread # noqa: E402
@ -74,11 +91,14 @@ async def slack_send(request: Request) -> JSONResponse:
form = await request.json() form = await request.json()
text = str(form.get("text", "")) text = str(form.get("text", ""))
mention_bot = bool(form.get("mention_bot", True)) mention_bot = bool(form.get("mention_bot", True))
# Sender defaults to the first test user (Alice) — the canonical owner the
# automated tests log in as; the mock UI passes the chosen test user.
user_id = str(form.get("user") or TEST_USERS[0]["slack_id"])
channel = DEMO_CHANNEL channel = DEMO_CHANNEL
ts = fakes.new_thread_ts() ts = fakes.new_thread_ts()
CURRENT_THREAD["thread_ts"] = ts CURRENT_THREAD["thread_ts"] = ts
fakes.add_slack_message(channel, ts, user=HUMAN_USER, text=text, is_bot=False) fakes.add_slack_message(channel, ts, user=user_id, text=text, is_bot=False)
payload = { payload = {
"type": "event_callback", "type": "event_callback",
@ -87,7 +107,7 @@ async def slack_send(request: Request) -> JSONResponse:
"event": { "event": {
"type": "app_mention" if mention_bot else "message", "type": "app_mention" if mention_bot else "message",
"channel": channel, "channel": channel,
"user": HUMAN_USER, "user": user_id,
"text": text, "text": text,
"ts": ts, "ts": ts,
"thread_ts": ts, "thread_ts": ts,
@ -131,6 +151,82 @@ async def control_login(request: Request) -> JSONResponse:
return resp return resp
@app.get("/control/login")
async def control_login_get(login: str = "", email: str = "", next_url: str = "") -> Response:
"""Browser login. With no ``login``, render a dropdown of the test users;
with ``?login=<u>`` (email resolved from the registry, or pass ``&email=``),
mint the session cookie and redirect into the dashboard. Use a separate
browser/profile per user — each has its own cookie jar."""
# Land on the dashboard origin (DASHBOARD_BASE_URL — the Vite HMR server in
# dev:mock), not this harness, so the cookie + the hot-reloading UI line up.
ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
dest = next_url or (f"{ui}/agents" if ui else "/agents")
if not login:
options = "".join(f'<option value="{u["login"]}">{u["name"]}</option>' for u in TEST_USERS)
return HTMLResponse(
f"""<!doctype html><meta charset=utf-8><title>Mock login</title>
<body style="font-family:system-ui;max-width:420px;margin:3rem auto;padding:0 1rem">
<h1 style="font-size:1.1rem">Sign in (mock)</h1>
<form method=get action=/control/login>
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Sign in</button>
</form>
<p style="color:#888;font-size:0.85rem">Tip: use a separate browser or profile per
user so their sessions don't overwrite each other.</p>
</body>"""
)
if not email:
match = next((u for u in TEST_USERS if u["login"] == login), None)
email = match["email"] if match else f"{login}@example.com"
token = issue_session(login=login, email=email, avatar_url=None)
resp = RedirectResponse(url=dest, status_code=303)
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, path="/")
return resp
@app.get("/dashboard/api/auth/login")
async def mock_github_login(redirect_to: str = "", login: str = "") -> Response:
"""Mock stand-in for GitHub OAuth: the dashboard's "Continue with GitHub"
button lands here. With no ``login``, render a picker of the fake GitHub
test users; once one is chosen, mint the real session cookie and redirect
back into the dashboard (``redirect_to``)."""
ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
dest = redirect_to or (f"{ui}/agents" if ui else "/agents")
if not login:
options = "".join(
f'<option value="{u["login"]}">{u["name"]} (@{u["login"]})</option>' for u in TEST_USERS
)
return HTMLResponse(
f"""<!doctype html><meta charset=utf-8><title>Continue with GitHub (mock)</title>
<body style="font-family:system-ui;max-width:420px;margin:3rem auto;padding:0 1rem">
<h1 style="font-size:1.1rem">Continue with GitHub (mock)</h1>
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to sign in as.</p>
<form method=get action=/dashboard/api/auth/login>
<input type=hidden name=redirect_to value="{dest}">
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Continue</button>
</form>
<p style="color:#888;font-size:0.85rem">Tip: use a separate browser or profile per
user so their sessions don't overwrite each other.</p>
</body>"""
)
match = next((u for u in TEST_USERS if u["login"] == login), None)
email = match["email"] if match else f"{login}@example.com"
token = issue_session(login=login, email=email, avatar_url=None)
resp = RedirectResponse(url=dest, status_code=303)
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, path="/")
return resp
# The real dashboard registered /dashboard/api/auth/login first (via
# include_router), so Starlette would match it before ours. Move ours to the
# front of the table so the mock picker shadows the real OAuth redirect.
for _i, _route in enumerate(app.router.routes):
if getattr(_route, "endpoint", None) is mock_github_login:
app.router.routes.insert(0, app.router.routes.pop(_i))
break
@app.post("/control/logout") @app.post("/control/logout")
async def control_logout() -> JSONResponse: async def control_logout() -> JSONResponse:
resp = JSONResponse({"ok": True}) resp = JSONResponse({"ok": True})
@ -195,11 +291,22 @@ async def ui_agents_thread(thread_id: str) -> FileResponse: # noqa: ARG001
return _ui_file("_shell.html") return _ui_file("_shell.html")
@app.get("/agents/{thread_id}/plan", response_class=HTMLResponse)
async def ui_agents_plan(thread_id: str) -> FileResponse: # noqa: ARG001
return _ui_file("_shell.html")
@app.get("/login", response_class=HTMLResponse) @app.get("/login", response_class=HTMLResponse)
async def ui_login() -> FileResponse: async def ui_login() -> FileResponse:
return _ui_file("_shell.html") return _ui_file("_shell.html")
@app.get("/mock/users")
async def mock_users() -> JSONResponse:
"""The named test users that drive the Slack sender + login dropdowns."""
return JSONResponse(TEST_USERS)
@app.get("/mock/slack/messages") @app.get("/mock/slack/messages")
async def slack_messages() -> JSONResponse: async def slack_messages() -> JSONResponse:
thread = CURRENT_THREAD["thread_ts"] thread = CURRENT_THREAD["thread_ts"]
@ -363,16 +470,19 @@ async def slack_reactions_add(request: Request) -> JSONResponse:
@app.get("/fake-slack/users.info") @app.get("/fake-slack/users.info")
async def slack_users_info(user: str = "") -> JSONResponse: async def slack_users_info(user: str = "") -> JSONResponse:
info = _SLACK_USERS.get(
user, {"name": "devuser", "real_name": "Dev User", "email": "dev@example.com"}
)
return _ok( return _ok(
{ {
"user": { "user": {
"id": user, "id": user,
"name": "devuser", "name": info["name"],
"real_name": "Dev User", "real_name": info["real_name"],
"profile": { "profile": {
"email": "dev@example.com", "email": info["email"],
"display_name": "Dev User", "display_name": info["real_name"],
"real_name": "Dev User", "real_name": info["real_name"],
}, },
} }
} }

View file

@ -12,8 +12,13 @@ process), so it runs before the first run regardless of import order. Idempotent
from __future__ import annotations from __future__ import annotations
import logging
import os
import e2e_env # noqa: F401 (sets env before any agent import) import e2e_env # noqa: F401 (sets env before any agent import)
logger = logging.getLogger(__name__)
_applied = False _applied = False
@ -34,12 +39,20 @@ def apply() -> None:
opr = importlib.import_module("agent.tools.open_pull_request") opr = importlib.import_module("agent.tools.open_pull_request")
from e2e_env import FAKE_GITHUB_API, FAKE_SLACK_API from e2e_env import FAKE_GITHUB_API, FAKE_SLACK_API
from fake_llm import FakeScriptedChatModel, build_script
def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001 # The LLM is the only agent-internal piece we fake, and only by default.
return FakeScriptedChatModel(script=build_script()) # Set E2E_REAL_LLM=1 to drive the harness (mock Slack/GitHub, real agent)
# with a real model — useful for manually exercising plan review etc. The
# provider key (e.g. ANTHROPIC_API_KEY) must be in the environment.
if os.environ.get("E2E_REAL_LLM"):
logger.warning("E2E_REAL_LLM set — using the real model factory, not the scripted fake")
else:
from fake_llm import FakeScriptedChatModel, build_script
server.make_model = _fake_make_model def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001
return FakeScriptedChatModel(script=build_script())
server.make_model = _fake_make_model
async def _dummy_install_token_with_expiry() -> tuple[str, str | None]: async def _dummy_install_token_with_expiry() -> tuple[str, str | None]:
return "dummy-installation-token", None return "dummy-installation-token", None

View file

@ -20,6 +20,7 @@
<body> <body>
<h1>#demo — Slack (mock)</h1> <h1>#demo — Slack (mock)</h1>
<div class="compose"> <div class="compose">
<select id="user" title="sender"></select>
<input id="text" value="<@U0BOT> please add a greet() helper and open a PR" /> <input id="text" value="<@U0BOT> please add a greet() helper and open a PR" />
<button id="send">Send</button> <button id="send">Send</button>
<button id="reset">Reset</button> <button id="reset">Reset</button>
@ -29,11 +30,22 @@
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const userNames = {}; // slack_id -> display name
async function loadUsers() {
try {
const users = await (await fetch("/mock/users")).json();
$("user").innerHTML = users
.map((u) => `<option value="${u.slack_id}">${u.name}</option>`)
.join("");
for (const u of users) userNames[u.slack_id] = u.name;
} catch (_) {}
}
function render(messages) { function render(messages) {
const html = messages const html = messages
.map((m) => { .map((m) => {
const who = m.is_bot ? "open-swe (bot)" : "you"; const who = m.is_bot ? "open-swe (bot)" : userNames[m.user] || m.user;
const linked = m.text.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, '<a href="$1">$2</a>'); const linked = m.text.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, '<a href="$1">$2</a>');
return `<div class="msg ${m.is_bot ? "bot" : ""}" data-bot="${m.is_bot}"><div class="who">${who}</div><div class="text">${linked}</div></div>`; return `<div class="msg ${m.is_bot ? "bot" : ""}" data-bot="${m.is_bot}"><div class="who">${who}</div><div class="text">${linked}</div></div>`;
}) })
@ -52,7 +64,11 @@
await fetch("/mock/slack/send", { await fetch("/mock/slack/send", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: $("text").value, mention_bot: $("mention").checked }), body: JSON.stringify({
text: $("text").value,
mention_bot: $("mention").checked,
user: $("user").value,
}),
}); });
poll(); poll();
}); });
@ -62,6 +78,7 @@
render([]); render([]);
}); });
loadUsers();
poll(); poll();
setInterval(poll, 1000); setInterval(poll, 1000);
</script> </script>

View file

@ -2,8 +2,8 @@ import { test, expect, type Page } from "@playwright/test";
// Drives the REAL built ui/ app (served same-origin from the harness) for the // Drives the REAL built ui/ app (served same-origin from the harness) for the
// Slack → web handoff. Only the LLM/GitHub/Slack/token boundaries are faked. // Slack → web handoff. Only the LLM/GitHub/Slack/token boundaries are faked.
const SAME_USER = { login: "dev-user", email: "dev@example.com" }; const SAME_USER = { login: "alice", email: "alice@example.com" };
const OTHER_USER = { login: "someone-else", email: "someone@example.com" }; const OTHER_USER = { login: "bob", email: "bob@example.com" };
async function loginAs(page: Page, user: { login: string; email: string }) { async function loginAs(page: Page, user: { login: string; email: string }) {
const res = await page.request.post("/control/login", { data: user }); const res = await page.request.post("/control/login", { data: user });

View file

@ -0,0 +1,145 @@
import {
test,
expect,
type APIRequestContext,
type Page,
} from "@playwright/test";
// Full plan-review flow, driven through the mock Slack UI + the real dashboard:
// user asks Open SWE in Slack to PLAN something ->
// agent calls enter_plan_mode, posts the plan-review link to Slack, writes the
// plan as a markdown file (save_plan), and posts "ready" back to Slack ->
// owner (user1) and a collaborator (user2) both open the plan and leave inline
// comments using BlockNote's native comment UI (synced live via Yjs) ->
// only the owner can approve -> on approval the agent implements, opens a PR,
// and replies in Slack with the link, having received the reviewers' comments.
// Only the LLM is faked; all agent + dashboard code runs for real.
const OWNER = { login: "alice", email: "alice@example.com" };
const COLLABORATOR = { login: "bob", email: "bob@example.com" };
async function botMessages(request: APIRequestContext): Promise<Array<string>> {
const res = await request.get("/mock/slack/messages");
const msgs = (await res.json()) as Array<{ text: string; is_bot: boolean }>;
return msgs.filter((m) => m.is_bot).map((m) => m.text);
}
// Create an inline comment via BlockNote's native UI: select text in the plan,
// click "Add comment" in the formatting toolbar, type, and save.
async function addInlineComment(page: Page, anchorText: string, comment: string) {
await page.locator(".bn-editor").getByText(anchorText, { exact: false }).first().selectText();
await page.locator('[data-test="addcomment"]').click();
await page.locator(".bn-comment-editor .ProseMirror").last().waitFor({ state: "visible" });
await page.keyboard.type(comment);
await page.locator('[data-test="save"]').click();
}
test.describe("Plan review (BlockNote native comments)", () => {
test("Slack plan request → inline comments → owner approves → PR", async ({
browser,
request,
}) => {
// 1. A user asks the bot to PLAN something in Slack.
await request.post("/control/reset");
const send = await request.post("/mock/slack/send", {
data: { text: "<@U0BOT> plan how to add a greet() helper", mention_bot: true },
});
const { thread_id: threadId } = (await send.json()) as { thread_id: string };
expect(threadId).toBeTruthy();
const planPath = `/agents/${threadId}/plan`;
// 1a. enter_plan_mode must actually engage, not error out. Its Command must
// carry a terminating ToolMessage; without it the tool call fails and is
// swallowed into an error tool message while the agent silently proceeds
// as a normal run. Assert the tool's success message landed in the
// thread (it wouldn't if the call had errored).
await expect
.poll(
async () => {
const res = await request.get(`/threads/${threadId}/state`);
const state = (await res.json()) as {
values?: { messages?: Array<{ content?: unknown }> };
};
return (state.values?.messages ?? [])
.map((m) =>
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
)
.some((c) => c.includes("Plan mode is active"));
},
{ timeout: 60_000 },
)
.toBe(true);
// 2. The agent shares the plan-review link, then announces the plan is ready.
await expect
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 })
.toMatch(/\/agents\/[^/]+\/plan\b/);
await expect
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 })
.toMatch(/ready for review/i);
// 3. The OWNER opens the conversation, sees the "Review plan" banner, and
// follows it into the plan — which renders inside the agents shell.
const ownerCtx = await browser.newContext();
await ownerCtx.request.post("/control/login", { data: OWNER });
const owner = await ownerCtx.newPage();
await owner.goto(`/agents/${threadId}`);
const reviewLink = owner.getByTestId("review-plan-link");
await expect(reviewLink).toBeVisible({ timeout: 30_000 });
await reviewLink.click();
await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}/plan$`));
await expect(owner.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 });
// The agents sidebar is present (plan lives inside the shell).
await expect(owner.getByText("Back to conversation")).toBeVisible();
await expect(owner.getByTestId("plan-document")).toContainText("greet", {
timeout: 30_000,
});
await expect(owner.getByTestId("approve-plan")).toBeVisible();
// Owner leaves an inline comment on the plan heading (a comment wraps the
// text in a `.bn-thread-mark`).
await addInlineComment(owner, "Add greet() helper", "Owner: looks solid, ship it.");
await expect(owner.locator(".bn-thread-mark")).toHaveCount(1);
// 4. A COLLABORATOR opens the same plan: sees it AND the owner's comment
// (synced live via Yjs), but has NO approve button.
const collabCtx = await browser.newContext();
await collabCtx.request.post("/control/login", { data: COLLABORATOR });
const collab = await collabCtx.newPage();
await collab.goto(planPath);
await expect(collab.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 });
await expect(collab.getByTestId("plan-document")).toContainText("greet", {
timeout: 30_000,
});
await expect(collab.locator(".bn-thread-mark")).toHaveCount(1); // owner's comment, synced
await expect(collab.getByTestId("approve-plan")).toHaveCount(0);
await expect(collab.getByTestId("reject-plan")).toBeVisible();
// Collaborator leaves inline feedback on a different part of the plan.
await addInlineComment(
collab,
"tiny greeting helper",
"Reviewer: please also add a docstring.",
);
await expect(collab.locator(".bn-thread-mark")).toHaveCount(2);
// 5. The owner sees the collaborator's comment sync in, then approves.
await expect(owner.locator(".bn-thread-mark")).toHaveCount(2);
await owner.getByTestId("approve-plan").click();
await expect(owner.getByTestId("plan-decision")).toContainText(/implementing/i);
// 6. The agent implements, opens a PR, and links it back in the Slack thread,
// echoing the reviewers' feedback it received — which proves the
// collaborator's comment synced to the owner and was harvested on approve.
await expect
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 90_000 })
.toMatch(/\/pull\//);
expect((await botMessages(request)).join("\n")).toMatch(/docstring/);
const prs = (await (await request.get("/mock/github/data")).json()) as Array<unknown>;
expect(prs.length).toBeGreaterThan(0);
await ownerCtx.close();
await collabCtx.close();
});
});

View file

@ -66,3 +66,29 @@ def test_resolve_project_id_caches_success(monkeypatch: pytest.MonkeyPatch) -> N
assert first == "pid-123" assert first == "pid-123"
assert second == "pid-123" assert second == "pid-123"
assert calls == [AGENT_TRACING_PROJECT] assert calls == [AGENT_TRACING_PROJECT]
def test_resolve_project_id_caches_failure(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
class _FakeClient:
def read_project(self, *, project_name: str) -> None:
calls.append(project_name)
raise RuntimeError("403 Forbidden")
monkeypatch.setattr(ls_utils, "_build_prod_langsmith_client", lambda: _FakeClient())
assert ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) is None
assert ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) is None
assert calls == [AGENT_TRACING_PROJECT]
def test_trace_url_none_when_tenant_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LANGSMITH_TENANT_ID_PROD", raising=False)
def _boom() -> None:
raise AssertionError("must not build a client when the tenant id is unset")
monkeypatch.setattr(ls_utils, "_build_prod_langsmith_client", _boom)
assert ls_utils.get_langsmith_trace_url("t5") is None

223
tests/test_plan_mode.py Normal file
View file

@ -0,0 +1,223 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from agent import server
from agent.dashboard import thread_api
from agent.prompt import construct_system_prompt
def test_plan_mode_prompt_included_when_enabled() -> None:
prompt = construct_system_prompt(working_dir="/work", plan_mode=True)
assert "Plan Mode (ACTIVE)" in prompt
assert "read-only research-and-planning phase" in prompt
def test_plan_mode_prompt_absent_by_default() -> None:
prompt = construct_system_prompt(working_dir="/work")
assert "Plan Mode (ACTIVE)" not in prompt
def test_plan_mode_excluded_tools_cover_mutating_tools() -> None:
excluded = server.PLAN_MODE_EXCLUDED_TOOLS
for tool in (
"write_file",
"edit_file",
"task",
"open_pull_request",
"request_pr_review",
"linear_create_issue",
"linear_update_issue",
"linear_delete_issue",
):
assert tool in excluded
# Read-only tools must stay available.
assert "read_file" not in excluded
assert "execute" not in excluded
class _FakeThreadsClient:
async def create(
self, *, thread_id: str, metadata: dict[str, Any], if_exists: str
) -> dict[str, Any]:
return {"thread_id": thread_id, "metadata": metadata}
async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
return {"thread_id": thread_id, "metadata": metadata}
async def get(self, thread_id: str) -> dict[str, Any]:
return {"thread_id": thread_id, "metadata": {}}
class _FakeRunsClient:
def __init__(self) -> None:
self.configurable: dict[str, Any] | None = None
async def create(
self,
thread_id: str,
assistant_id: str,
*,
input: dict[str, Any],
config: dict[str, Any],
if_not_exists: str = "reject",
stream_mode: list[str] | None = None,
stream_resumable: bool = False,
) -> dict[str, str]:
self.configurable = config["configurable"]
return {"run_id": "run-id"}
class _FakeLangGraphClient:
def __init__(self) -> None:
self.threads = _FakeThreadsClient()
self.runs = _FakeRunsClient()
@pytest.fixture
def dashboard_run_client(monkeypatch: pytest.MonkeyPatch) -> _FakeLangGraphClient:
client = _FakeLangGraphClient()
async def fake_get_profile(login: str) -> dict[str, Any]:
return {}
async def fake_ensure_token(login: str) -> None:
return None
async def fake_resolve_email(login: str, profile: dict[str, Any]) -> str:
return "octo@example.com"
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
return client
def _run_start_command(plan_mode: bool | None) -> dict[str, Any]:
configurable: dict[str, Any] = {}
if plan_mode is not None:
configurable["plan_mode"] = plan_mode
return {
"method": "run.start",
"params": {
"input": {"messages": [{"role": "user", "content": "do work"}]},
"config": {"configurable": configurable},
},
}
def test_run_start_passes_plan_mode_when_enabled(
dashboard_run_client: _FakeLangGraphClient,
) -> None:
enriched = asyncio.run(
thread_api._enrich_run_start_command(
"thread-id",
"octo",
_run_start_command(True),
metadata={"source": "dashboard", "github_login": "octo"},
creating=False,
)
)
configurable = enriched["params"]["config"]["configurable"]
assert configurable["plan_mode"] is True
def test_run_start_omits_plan_mode_when_disabled(
dashboard_run_client: _FakeLangGraphClient,
) -> None:
enriched = asyncio.run(
thread_api._enrich_run_start_command(
"thread-id",
"octo",
_run_start_command(None),
metadata={"source": "dashboard", "github_login": "octo"},
creating=False,
)
)
configurable = enriched["params"]["config"]["configurable"]
assert "plan_mode" not in configurable
def test_thread_summary_reports_plan_mode() -> None:
summary = thread_api._thread_summary(
{"thread_id": "t1", "metadata": {"source": "dashboard", "plan_mode": True}}
)
assert summary["planMode"] is True
summary_off = thread_api._thread_summary(
{"thread_id": "t2", "metadata": {"source": "dashboard"}}
)
assert summary_off["planMode"] is False
def test_plan_mode_guidance_section_always_present() -> None:
"""The guidance section telling the agent about enter_plan_mode should be in every prompt."""
prompt = construct_system_prompt(working_dir="/work", plan_mode=False)
assert "enter_plan_mode" in prompt
assert "Plan Mode" in prompt
def test_plan_mode_guidance_section_present_when_enabled() -> None:
prompt = construct_system_prompt(working_dir="/work", plan_mode=True)
assert "enter_plan_mode" in prompt
assert "Plan Mode (ACTIVE)" in prompt
def test_enter_plan_mode_tool_returns_command() -> None:
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool as as_tool
from langgraph.types import Command
from agent.tools.enter_plan_mode import enter_plan_mode
# Wrap as the agent does so the InjectedToolCallId is supplied from the call.
wrapped = as_tool(enter_plan_mode)
result = wrapped.invoke(
{"name": "enter_plan_mode", "args": {}, "id": "call-1", "type": "tool_call"}
)
assert isinstance(result, Command)
assert result.update["plan_mode"] is True
messages = result.update["messages"]
assert len(messages) == 1
assert isinstance(messages[0], ToolMessage)
assert messages[0].tool_call_id == "call-1"
def test_enter_plan_mode_exported() -> None:
from agent.tools import enter_plan_mode
assert callable(enter_plan_mode)
def test_build_plan_approval_blocks_has_three_buttons() -> None:
from agent.tools.slack_thread_reply import _build_plan_approval_blocks
blocks = _build_plan_approval_blocks("Here is my plan")
assert len(blocks) == 2
assert blocks[0]["type"] == "section"
actions = blocks[1]
assert actions["type"] == "actions"
elements = actions["elements"]
assert len(elements) == 3
texts = [e["text"]["text"] for e in elements]
assert "Approve & Implement" in texts
assert "Revise Plan" in texts
assert "Cancel" in texts
def test_build_plan_approval_blocks_values_have_plan_approval_type() -> None:
import json
from agent.tools.slack_thread_reply import _build_plan_approval_blocks
blocks = _build_plan_approval_blocks("plan text")
for element in blocks[1]["elements"]:
value = json.loads(element["value"])
assert value["type"] == "plan_approval"
assert value["action"] in ("approve", "revise", "cancel")

156
tests/test_plan_review.py Normal file
View file

@ -0,0 +1,156 @@
from __future__ import annotations
from typing import Any
import pytest
def test_dashboard_plan_url_uses_plan_path(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://example.test")
from agent.utils.dashboard_links import dashboard_plan_url
assert dashboard_plan_url("abc-123") == "https://example.test/agents/abc-123/plan"
def test_dashboard_plan_url_none_without_thread() -> None:
from agent.utils.dashboard_links import dashboard_plan_url
assert dashboard_plan_url("") is None
def test_format_comments_with_quote_and_author() -> None:
from agent.dashboard.plan_api import PlanComment, _format_comments
text = _format_comments(
[
PlanComment(author="alice", body="add a docstring", quote="def greet"),
PlanComment(author="bob", body="looks good", resolved=True),
PlanComment(author="carol", body=" "), # blank → skipped
]
)
assert 'On "def greet"' in text
assert "alice: add a docstring" in text
assert "bob (resolved): looks good" in text
assert "carol" not in text
def test_format_comments_empty() -> None:
from agent.dashboard.plan_api import _format_comments
assert _format_comments([]) == ""
def test_plan_decision_body_defaults() -> None:
from agent.dashboard.plan_api import PlanComment, PlanDecisionBody
body = PlanDecisionBody()
assert body.comments == []
comment = PlanComment(body="hi")
assert comment.author is None
assert comment.resolved is False
def test_save_plan_requires_run_context() -> None:
from agent.tools.save_plan import save_plan
# No LangGraph run context → no thread_id → graceful error, not a crash.
result = save_plan("## Plan")
assert result["success"] is False
assert "thread_id" in result["error"]
def test_save_plan_rejects_empty_markdown() -> None:
from agent.tools.save_plan import save_plan
result = save_plan(" ")
assert result["success"] is False
assert "empty" in result["error"]
def test_plan_routes_registered() -> None:
from agent.webapp import app
paths = {getattr(route, "path", "") for route in app.routes}
assert "/dashboard/api/plan/{thread_id}" in paths
assert "/dashboard/api/plan/{thread_id}/approve" in paths
assert "/dashboard/api/plan/{thread_id}/reject" in paths
assert "/dashboard/api/plan/yjs/{thread_id}" in paths
def test_save_plan_exported_and_wired() -> None:
from agent.tools import save_plan
assert callable(save_plan)
def test_plan_status_constants() -> None:
from agent.dashboard import plan_store
assert plan_store.PLAN_STATUS_READY == "ready"
assert plan_store.PLAN_STATUS_PLANNING == "planning"
assert plan_store.PLAN_STATUS_APPROVED == "approved"
assert plan_store.PLAN_STATUS_REVISING == "revising"
def test_http_request_excluded_in_plan_mode() -> None:
from agent.server import PLAN_MODE_EXCLUDED_TOOLS
assert "http_request" in PLAN_MODE_EXCLUDED_TOOLS
class _FakeWS:
def __init__(self, origin: str | None) -> None:
self.headers = {"origin": origin} if origin is not None else {}
def test_collab_origin_allowed_noop_without_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
from agent.dashboard.plan_collab import _origin_allowed
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
monkeypatch.delenv("DASHBOARD_ALLOWED_ORIGINS", raising=False)
# No configured origins (local/dev): the gate is a no-op.
assert _origin_allowed(_FakeWS(None)) is True
assert _origin_allowed(_FakeWS("https://evil.test")) is True
def test_collab_origin_allowed_enforced_with_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
from agent.dashboard.plan_collab import _origin_allowed
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example")
monkeypatch.delenv("DASHBOARD_ALLOWED_ORIGINS", raising=False)
assert _origin_allowed(_FakeWS("https://app.example")) is True
assert _origin_allowed(_FakeWS("https://evil.test")) is False
assert _origin_allowed(_FakeWS(None)) is False
class _FakeReq:
def __init__(self, tools: list[Any], state: dict[str, Any]) -> None:
self.tools = tools
self.state = state
def override(self, **kw: Any) -> _FakeReq:
return _FakeReq(kw.get("tools", self.tools), self.state)
def _names(req: _FakeReq) -> set[str]:
return {t["name"] for t in req.tools}
def test_plan_mode_middleware_initial_always_filters() -> None:
from agent.middleware import PlanModeMiddleware
mw = PlanModeMiddleware(excluded=frozenset({"write_file"}), initial=True)
req = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {})
assert _names(mw._filter(req)) == {"read_file"}
def test_plan_mode_middleware_self_activation_via_state() -> None:
from agent.middleware import PlanModeMiddleware
mw = PlanModeMiddleware(excluded=frozenset({"write_file"}), initial=False)
# Plan mode not yet active: nothing filtered.
off = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {})
assert _names(mw._filter(off)) == {"read_file", "write_file"}
# After enter_plan_mode sets state: the next request is filtered.
on = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {"plan_mode": True})
assert _names(mw._filter(on)) == {"read_file"}

View file

@ -6,6 +6,9 @@
"name": "open-swe-dashboard", "name": "open-swe-dashboard",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",
"@blocknote/core": "^0.51.4",
"@blocknote/mantine": "^0.51.4",
"@blocknote/react": "^0.51.4",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@langchain/core": "^1.1.48", "@langchain/core": "^1.1.48",
"@langchain/langgraph-sdk": "^1.9.21", "@langchain/langgraph-sdk": "^1.9.21",
@ -38,6 +41,8 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"y-websocket": "^3.0.0",
"yjs": "^13.6.27",
}, },
"devDependencies": { "devDependencies": {
"@tanstack/devtools-vite": "^0.6.0", "@tanstack/devtools-vite": "^0.6.0",
@ -52,7 +57,7 @@
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.5",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.0", "vitest": "^4.1.0",
"web-vitals": "^5.1.0", "web-vitals": "^5.1.0",
@ -272,6 +277,12 @@
"@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="], "@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="],
"@blocknote/core": ["@blocknote/core@0.51.4", "", { "dependencies": { "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4", "@tanstack/store": "^0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-code": "^3.13.0", "@tiptap/extension-horizontal-rule": "^3.13.0", "@tiptap/extension-italic": "^3.13.0", "@tiptap/extension-paragraph": "^3.13.0", "@tiptap/extension-strike": "^3.13.0", "@tiptap/extension-text": "^3.13.0", "@tiptap/extension-underline": "^3.13.0", "@tiptap/extensions": "^3.13.0", "@tiptap/pm": "^3.13.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "prosemirror-highlight": "^0.15.1", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.3", "prosemirror-transform": "^1.11.0", "prosemirror-view": "^1.41.4", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" } }, "sha512-uR7/BlW6VVlCx/JzfGbkaLGz7O5uI3bu2tbNIdzGTfSloiEp8Qc14if36Dy7DDs+dVWfZgwds0jWiGfE3AzJiw=="],
"@blocknote/mantine": ["@blocknote/mantine@0.51.4", "", { "dependencies": { "@blocknote/core": "0.51.4", "@blocknote/react": "0.51.4", "react-icons": "^5.5.0" }, "peerDependencies": { "@mantine/core": "^8.3.11 || ^9.0.2", "@mantine/hooks": "^8.3.11 || ^9.0.2", "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "sha512-Ka7QjzhgB/T2ekwtXgwa9zIiiixAm4dob3FklJIedu+9j/eO4auokmRnWAhZQmNJsOdE7mQTUmnv6X4k/lHyCQ=="],
"@blocknote/react": ["@blocknote/react@0.51.4", "", { "dependencies": { "@blocknote/core": "0.51.4", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.18", "@floating-ui/utils": "^0.2.10", "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/pm": "^3.13.0", "@tiptap/react": "^3.13.0", "@types/use-sync-external-store": "1.5.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lodash.merge": "^4.6.2", "react-icons": "^5.5.0", "use-sync-external-store": "1.6.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "sha512-tWe039t/Gkj9YDoEXruclXQvI+toI0ctcyikbHa8dm3Q6zTgy24+T/P1t1fYSNPBkSnhq+ILz2fLLnCK994qkA=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="],
@ -300,6 +311,8 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
@ -374,12 +387,16 @@
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
"@handlewithcare/prosemirror-inputrules": ["@handlewithcare/prosemirror-inputrules@0.1.4", "", { "dependencies": { "prosemirror-history": "^1.4.1", "prosemirror-transform": "^1.0.0" }, "peerDependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
@ -418,6 +435,10 @@
"@langchain/react": ["@langchain/react@1.0.22", "", { "dependencies": { "@langchain/langgraph-sdk": "1.9.21" }, "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19" } }, "sha512-e3llirWyH6RI6XoTwm7i9e7jiV3A6GDp50JCFKyo8XfZiufE6HiMzZHtpiMLkR/JezfdOTEHDyEC148C0gVB8A=="], "@langchain/react": ["@langchain/react@1.0.22", "", { "dependencies": { "@langchain/langgraph-sdk": "1.9.21" }, "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19" } }, "sha512-e3llirWyH6RI6XoTwm7i9e7jiV3A6GDp50JCFKyo8XfZiufE6HiMzZHtpiMLkR/JezfdOTEHDyEC148C0gVB8A=="],
"@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="],
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
@ -660,7 +681,7 @@
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.19", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/router-core": "1.171.13", "@tanstack/start-server-core": "1.169.14" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-+eMpAwDreQvCwgX45MdUHTUCF/Wad36+PwQafe6W5wa3qVkGyN3P131ShGyRwT/0WwKa5EVGdW1zFgwby8UNqA=="], "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.19", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/router-core": "1.171.13", "@tanstack/start-server-core": "1.169.14" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-+eMpAwDreQvCwgX45MdUHTUCF/Wad36+PwQafe6W5wa3qVkGyN3P131ShGyRwT/0WwKa5EVGdW1zFgwby8UNqA=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], "@tanstack/react-store": ["@tanstack/react-store@0.7.7", "", { "dependencies": { "@tanstack/store": "0.7.7", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg=="],
"@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="], "@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="],
@ -684,7 +705,7 @@
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.15", "", { "dependencies": { "@tanstack/router-core": "1.171.13" } }, "sha512-Jy0q4vdG6pv76N92+X+ag3fuOV2zINQagYyMN1/es7tPI1vzpKECIU8AqHqzI6ahkwaph7XDvmfUkiLJ3i4LOA=="], "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.15", "", { "dependencies": { "@tanstack/router-core": "1.171.13" } }, "sha512-Jy0q4vdG6pv76N92+X+ag3fuOV2zINQagYyMN1/es7tPI1vzpKECIU8AqHqzI6ahkwaph7XDvmfUkiLJ3i4LOA=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], "@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
@ -692,6 +713,34 @@
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@tiptap/core": ["@tiptap/core@3.27.1", "", { "peerDependencies": { "@tiptap/pm": "3.27.1" } }, "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ=="],
"@tiptap/extension-bold": ["@tiptap/extension-bold@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ=="],
"@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.27.1", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw=="],
"@tiptap/extension-code": ["@tiptap/extension-code@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w=="],
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.27.1", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q=="],
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg=="],
"@tiptap/extension-italic": ["@tiptap/extension-italic@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA=="],
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q=="],
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q=="],
"@tiptap/extension-text": ["@tiptap/extension-text@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ=="],
"@tiptap/extensions": ["@tiptap/extensions@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw=="],
"@tiptap/pm": ["@tiptap/pm@3.27.1", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.7", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.0", "prosemirror-transform": "^1.12.0", "prosemirror-view": "^1.41.8" } }, "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw=="],
"@tiptap/react": ["@tiptap/react@3.27.1", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.27.1", "@tiptap/extension-floating-menu": "^3.27.1" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg=="],
"@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="], "@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="],
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
@ -804,6 +853,8 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@1.5.0", "", {}, "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw=="],
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="],
@ -1158,6 +1209,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
@ -1178,6 +1231,8 @@
"electron-to-chromium": ["electron-to-chromium@1.5.371", "", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="], "electron-to-chromium": ["electron-to-chromium@1.5.371", "", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="],
"emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@ -1274,6 +1329,8 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@ -1336,6 +1393,8 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
"get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="], "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="],
@ -1538,6 +1597,8 @@
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
@ -1590,6 +1651,8 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@ -1622,6 +1685,8 @@
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
@ -1818,6 +1883,8 @@
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
"orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
@ -1902,6 +1969,34 @@
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="],
"prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="],
"prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="],
"prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="],
"prosemirror-highlight": ["prosemirror-highlight@0.15.2", "", { "peerDependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0", "@types/hast": "^3.0.0", "highlight.js": "^11.9.0", "lowlight": "^3.1.0", "prosemirror-model": "^1.19.3", "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.8.0", "prosemirror-view": "^1.32.4", "refractor": "^5.0.0", "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^1.0.0" }, "optionalPeers": ["@lezer/common", "@lezer/highlight", "@shikijs/types", "@types/hast", "highlight.js", "lowlight", "prosemirror-model", "prosemirror-state", "prosemirror-transform", "prosemirror-view", "refractor", "sugar-high"] }, "sha512-G5Wn4kn+MZMus/7vSh4ZNsC+3yrMpjKsgV6QKKYfwKOPMknRX+3H6FWr+E+Qji4xhmkqdXiToG1VTQ2bZn5b0w=="],
"prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="],
"prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="],
"prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="],
"prosemirror-model": ["prosemirror-model@1.25.9", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA=="],
"prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="],
"prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="],
"prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="],
"prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="],
"prosemirror-view": ["prosemirror-view@1.41.9", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@ -1922,8 +2017,18 @@
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
@ -1984,6 +2089,8 @@
"rollup": ["rollup@4.61.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="], "rollup": ["rollup@4.61.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="],
"rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
@ -2114,6 +2221,8 @@
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
@ -2234,6 +2343,16 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="],
"use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="],
"use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@ -2262,6 +2381,8 @@
"vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="], "vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
@ -2336,8 +2457,16 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"y-prosemirror": ["y-prosemirror@1.3.7", "", { "dependencies": { "lib0": "^0.2.109" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg=="],
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
"y-websocket": ["y-websocket@3.0.0", "", { "dependencies": { "lib0": "^0.2.102", "y-protocols": "^1.0.5" }, "peerDependencies": { "yjs": "^13.5.6" } }, "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="], "yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="],
@ -2360,6 +2489,8 @@
"@langchain/langgraph-sdk/p-queue": ["p-queue@9.3.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="], "@langchain/langgraph-sdk/p-queue": ["p-queue@9.3.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="],
"@mantine/core/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@ -2380,12 +2511,16 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/router-utils/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "@tanstack/router-utils/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@tanstack/start-plugin-core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@tanstack/start-plugin-core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@tiptap/react/@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
@ -2508,6 +2643,8 @@
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],

View file

@ -13,6 +13,9 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",
"@blocknote/core": "^0.51.4",
"@blocknote/mantine": "^0.51.4",
"@blocknote/react": "^0.51.4",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@langchain/core": "^1.1.48", "@langchain/core": "^1.1.48",
"@langchain/langgraph-sdk": "^1.9.21", "@langchain/langgraph-sdk": "^1.9.21",
@ -44,7 +47,9 @@
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4",
"y-websocket": "^3.0.0",
"yjs": "^13.6.27"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/devtools-vite": "^0.6.0", "@tanstack/devtools-vite": "^0.6.0",

View file

@ -1,5 +1,7 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Link } from "@tanstack/react-router"
import { useStreamContext as useAgentThreadStream } from "@langchain/react" import { useStreamContext as useAgentThreadStream } from "@langchain/react"
import { Map as MapIcon } from "lucide-react"
import type { AgentThread, Message } from "@/lib/agents/types" import type { AgentThread, Message } from "@/lib/agents/types"
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions" import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
@ -36,9 +38,15 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
}, [models, thread.model, thread.effort]) }, [models, thread.model, thread.effort])
const [selection, setSelection] = useState<ModelSelection | null>(null) const [selection, setSelection] = useState<ModelSelection | null>(null)
const activeSelection = selection ?? threadSelection ?? defaultSelection const activeSelection = selection ?? threadSelection ?? defaultSelection
const [planMode, setPlanMode] = useState<boolean | null>(null)
const activePlanMode = planMode ?? thread.planMode ?? false
const baseMessages = useMemo<Array<Message>>(() => { const baseMessages = useMemo<Array<Message>>(() => {
const live = streamMessagesToUi(stream.messages, stream.toolCalls, stream.subagents) const live = streamMessagesToUi(
stream.messages,
stream.toolCalls,
stream.subagents
)
if (live.length > 0) return live if (live.length > 0) return live
// Optimistic transcript seeded by `AgentsHome` on thread creation (the // Optimistic transcript seeded by `AgentsHome` on thread creation (the
// only case where a fetched thread carries messages — `getThread` returns // only case where a fetched thread carries messages — `getThread` returns
@ -46,12 +54,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
// lands in `stream.messages`. // lands in `stream.messages`.
if (thread.messages.length > 0) return thread.messages if (thread.messages.length > 0) return thread.messages
return live return live
}, [ }, [stream.messages, stream.toolCalls, stream.subagents, thread.messages])
stream.messages,
stream.toolCalls,
stream.subagents,
thread.messages,
])
const hasMessages = baseMessages.length > 0 const hasMessages = baseMessages.length > 0
const isStreaming = thread.status === "running" || stream.isLoading const isStreaming = thread.status === "running" || stream.isLoading
@ -67,6 +70,26 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
className="flex min-w-0 flex-1 flex-col" className="flex min-w-0 flex-1 flex-col"
style={isMobile ? undefined : { minWidth: PANEL_MIN_CHAT_WIDTH }} style={isMobile ? undefined : { minWidth: PANEL_MIN_CHAT_WIDTH }}
> >
{thread.planStatus &&
thread.planStatus !== "approved" &&
thread.planStatus !== "cancelled" && (
<Link
to="/agents/$threadId/plan"
params={{ threadId: thread.id }}
data-testid="review-plan-link"
className="flex items-center justify-between gap-2 border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-2 text-xs text-[var(--ui-text)] hover:bg-[var(--ui-panel-2)]"
>
<span className="flex items-center gap-2">
<MapIcon className="size-3.5 text-[var(--ui-accent)]" />
{thread.planStatus === "revising"
? "The agent is revising the plan."
: "A plan is ready for your review."}
</span>
<span className="font-medium text-[var(--ui-accent)]">
Review plan →
</span>
</Link>
)}
{hasMessages ? ( {hasMessages ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<Messages <Messages
@ -89,11 +112,14 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
images, images,
model_id: activeSelection?.modelId ?? null, model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null, effort: activeSelection?.effort ?? null,
plan_mode: activePlanMode,
}) })
} }
models={models} models={models}
selection={activeSelection} selection={activeSelection}
onSelectionChange={setSelection} onSelectionChange={setSelection}
planMode={activePlanMode}
onPlanModeChange={setPlanMode}
/> />
</div> </div>
</div> </div>

View file

@ -44,6 +44,7 @@ export function AgentsHome() {
const { models, defaultSelection } = useModelOptions() const { models, defaultSelection } = useModelOptions()
const [selection, setSelection] = useState<ModelSelection | null>(null) const [selection, setSelection] = useState<ModelSelection | null>(null)
const activeSelection = selection ?? defaultSelection const activeSelection = selection ?? defaultSelection
const [planMode, setPlanMode] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const reposQuery = useRepos() const reposQuery = useRepos()
@ -94,6 +95,7 @@ export function AgentsHome() {
} }
if (repo) configurable.repo = repo if (repo) configurable.repo = repo
if (repoOverride === null) configurable.repo_explicitly_none = true if (repoOverride === null) configurable.repo_explicitly_none = true
if (planMode) configurable.plan_mode = true
stream stream
.submit( .submit(
@ -125,6 +127,8 @@ export function AgentsHome() {
repos={reposQuery.data?.repositories} repos={reposQuery.data?.repositories}
selectedRepo={repo} selectedRepo={repo}
onRepoChange={setRepoOverride} onRepoChange={setRepoOverride}
planMode={planMode}
onPlanModeChange={setPlanMode}
/> />
</div> </div>
</div> </div>

View file

@ -0,0 +1,256 @@
import "@blocknote/core/fonts/inter.css"
import "@blocknote/mantine/style.css"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import * as Y from "yjs"
import { WebsocketProvider } from "y-websocket"
import {
CommentsExtension,
DefaultThreadStoreAuth,
YjsThreadStore,
} from "@blocknote/core/comments"
import { useCreateBlockNote } from "@blocknote/react"
import { BlockNoteView } from "@blocknote/mantine"
import type { HarvestedComment, PlanData } from "@/lib/plan"
import { approvePlan, planCollabUrl, rejectPlan } from "@/lib/plan"
import { Button } from "@/components/ui/button"
import { useResolvedTheme } from "@/lib/theme"
const CURSOR_COLORS = [
"#e11d48",
"#2563eb",
"#059669",
"#d97706",
"#7c3aed",
"#0891b2",
]
function colorFor(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i++)
hash = (hash * 31 + id.charCodeAt(i)) >>> 0
return CURSOR_COLORS[hash % CURSOR_COLORS.length] ?? "#2563eb"
}
function bodyToText(body: unknown): string {
if (typeof body === "string") return body
if (!Array.isArray(body)) return ""
const parts: Array<string> = []
for (const block of body) {
const content = (block as { content?: unknown }).content
if (typeof content === "string") parts.push(content)
else if (Array.isArray(content))
parts.push(
content.map((node) => (node as { text?: string }).text ?? "").join("")
)
}
return parts.join("\n").trim()
}
export function PlanReview({ plan }: { plan: PlanData }) {
const resolvedTheme = useResolvedTheme()
const { doc, provider, threadStore } = useMemo(() => {
const ydoc = new Y.Doc()
const wsProvider = new WebsocketProvider(
planCollabUrl(),
plan.threadId,
ydoc,
{ connect: true }
)
wsProvider.awareness.setLocalStateField("user", {
name: plan.user.name,
color: colorFor(plan.user.id),
})
// Everyone with read access edits the plan and comments alike — there's no
// owner/commenter split for the document (only approval is owner-gated). The
// collab WS already relays to any readable user, so this matches the server.
const auth = new DefaultThreadStoreAuth(plan.user.id, "editor")
const store = new YjsThreadStore(plan.user.id, ydoc.getMap("threads"), auth)
return { doc: ydoc, provider: wsProvider, threadStore: store }
}, [plan.threadId, plan.user.id, plan.user.name])
// Defer teardown so React StrictMode's dev-only unmount→remount of the same
// memoized provider doesn't destroy instances the remount reuses: the remount
// cancels the pending destroy before it runs. A real unmount still tears down.
const pendingDestroy = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (pendingDestroy.current) {
clearTimeout(pendingDestroy.current)
pendingDestroy.current = null
}
return () => {
pendingDestroy.current = setTimeout(() => {
provider.destroy()
doc.destroy()
}, 0)
}
}, [provider, doc])
// Track reviewers' display names from awareness so harvested comments are
// attributed to people, not raw ids.
const usersRef = useRef<Record<string, string>>({
[plan.user.id]: plan.user.name,
})
useEffect(() => {
const sync = () => {
for (const state of provider.awareness.getStates().values()) {
const user = (state as { user?: { name?: string } }).user
if (user?.name) usersRef.current[user.name] = user.name
}
}
provider.awareness.on("change", sync)
sync()
return () => provider.awareness.off("change", sync)
}, [provider])
const resolveUsers = useCallback(
(userIds: Array<string>) =>
Promise.resolve(
userIds.map((id) => ({
id,
username: usersRef.current[id] ?? id,
avatarUrl: "",
}))
),
[]
)
const editor = useCreateBlockNote(
{
collaboration: {
provider,
fragment: doc.getXmlFragment("blocknote"),
user: { name: plan.user.name, color: colorFor(plan.user.id) },
showCursorLabels: "activity",
},
extensions: [CommentsExtension({ threadStore, resolveUsers })],
},
[provider, threadStore, resolveUsers]
)
// Seed the shared document from the agent's plan markdown the first time
// anyone opens an empty plan. The `seeded` flag + sync barrier prevent a
// second reviewer from double-seeding.
useEffect(() => {
let cancelled = false
const seed = async (isSynced: boolean) => {
if (!isSynced || cancelled) return
const meta = doc.getMap<boolean>("meta")
if (meta.get("seeded")) return
const blocks = editor.document
const empty =
blocks.length === 0 ||
(blocks.length === 1 && bodyToText(blocks) === "")
if (!empty || !plan.markdown.trim()) return
const parsed = await editor.tryParseMarkdownToBlocks(plan.markdown)
// Re-check after the await: another reviewer may have seeded concurrently.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (cancelled || !parsed.length || meta.get("seeded")) return
editor.replaceBlocks(editor.document, parsed)
meta.set("seeded", true)
}
provider.on("sync", seed)
if (provider.synced) void seed(true)
return () => {
cancelled = true
provider.off("sync", seed)
}
}, [editor, provider, doc, plan.markdown])
const harvest = useCallback((): Array<HarvestedComment> => {
const out: Array<HarvestedComment> = []
for (const thread of threadStore.getThreads().values()) {
for (const comment of thread.comments) {
if (!comment.body) continue
out.push({
author: usersRef.current[comment.userId] ?? comment.userId,
body: bodyToText(comment.body),
resolved: thread.resolved,
})
}
}
return out
}, [threadStore])
const [decision, setDecision] = useState<string | null>(null)
const [busy, setBusy] = useState<"approve" | "reject" | null>(null)
const decide = useCallback(
async (kind: "approve" | "reject") => {
setBusy(kind)
try {
const comments = harvest()
if (kind === "approve") await approvePlan(plan.threadId, comments)
else await rejectPlan(plan.threadId, comments)
setDecision(
kind === "approve"
? "Plan approved — the agent is implementing it."
: "Changes requested — the agent is revising the plan."
)
} finally {
setBusy(null)
}
},
[harvest, plan.threadId]
)
return (
<div
data-testid="plan-review"
className="flex min-h-0 flex-1 flex-col bg-[var(--ui-bg)] text-[var(--ui-text)]"
>
<div className="flex items-center justify-between gap-4 border-b border-[var(--ui-border)] px-6 py-3">
<div>
<h1 className="text-base font-semibold text-[var(--ui-text)]">
Implementation plan
</h1>
<p className="text-xs text-[var(--ui-text-dim)]">
Reviewing as {plan.user.name}
{plan.isOwner ? " (owner)" : ""} · status:{" "}
<span data-testid="plan-status">{plan.status}</span>
{" · edit the plan, or select text to comment"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{decision && (
<span
data-testid="plan-decision"
className="text-xs text-[var(--ui-text-dim)]"
>
{decision}
</span>
)}
{plan.isOwner && (
<Button
data-testid="approve-plan"
disabled={busy !== null || decision !== null}
onClick={() => void decide("approve")}
>
Approve
</Button>
)}
<Button
data-testid="reject-plan"
variant="secondary"
disabled={busy !== null || decision !== null}
onClick={() => void decide("reject")}
>
Request changes
</Button>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-auto py-4"
data-testid="plan-document"
data-color-scheme={resolvedTheme}
>
<BlockNoteView
editor={editor}
editable={!decision}
theme={resolvedTheme}
/>
</div>
</div>
)
}

View file

@ -1,4 +1,11 @@
import { ArrowUp, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react" import {
ArrowUp,
ChevronDown,
ImagePlus,
LoaderCircle,
Map as MapIcon,
X,
} from "lucide-react"
import { StopIcon } from "@phosphor-icons/react" import { StopIcon } from "@phosphor-icons/react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { useStreamContext as useAgentThreadStream } from "@langchain/react" import { useStreamContext as useAgentThreadStream } from "@langchain/react"
@ -119,6 +126,9 @@ export interface CloudPromptBarProps {
repos?: Array<{ full_name: string }> repos?: Array<{ full_name: string }>
selectedRepo?: string | null selectedRepo?: string | null
onRepoChange?: (repo: string | null) => void onRepoChange?: (repo: string | null) => void
/** When provided, a Plan mode toggle is shown. Plan mode researches read-only and proposes a plan before editing. */
planMode?: boolean
onPlanModeChange?: (next: boolean) => void
} }
function fileToImageChunk(file: File): Promise<ImageChunk | null> { function fileToImageChunk(file: File): Promise<ImageChunk | null> {
@ -160,6 +170,8 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
repos, repos,
selectedRepo = null, selectedRepo = null,
onRepoChange, onRepoChange,
planMode = false,
onPlanModeChange,
}: CloudPromptBarProps) { }: CloudPromptBarProps) {
const [value, setValue] = useState("") const [value, setValue] = useState("")
const [pendingImages, setPendingImages] = useState<Array<ImageChunk>>([]) const [pendingImages, setPendingImages] = useState<Array<ImageChunk>>([])
@ -320,6 +332,10 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
e.preventDefault() e.preventDefault()
void handleSubmit() void handleSubmit()
} }
if (e.key === "Tab" && e.shiftKey && onPlanModeChange) {
e.preventDefault()
onPlanModeChange(!planMode)
}
} }
const pickerDisabled = combos.length === 0 || !onSelectionChange const pickerDisabled = combos.length === 0 || !onSelectionChange
@ -469,6 +485,24 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
)} )}
</div> </div>
{onPlanModeChange && (
<button
type="button"
onClick={() => onPlanModeChange(!planMode)}
aria-pressed={planMode}
title="Plan mode: research read-only and propose a plan before editing (Shift+Tab)"
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2 py-1 text-[12px] transition-colors",
planMode
? "border-[var(--ui-accent)] bg-[var(--ui-accent)]/10 text-[color:var(--ui-accent)]"
: "border-[var(--ui-border)] text-[color:var(--ui-text-muted)] hover:bg-[var(--ui-panel-2)] hover:text-[color:var(--ui-text)]"
)}
>
<MapIcon className="size-3.5" />
<span>Plan</span>
</button>
)}
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}

View file

@ -17,6 +17,7 @@ export interface ThreadMessageRequest {
images?: Array<ImageChunk> images?: Array<ImageChunk>
model_id?: string | null model_id?: string | null
effort?: string | null effort?: string | null
plan_mode?: boolean
} }
export interface ScheduleCreateRequest { export interface ScheduleCreateRequest {

View file

@ -48,6 +48,7 @@ export function useSubmitAgentMessage(threadId: string) {
images: vars.images, images: vars.images,
model_id: vars.model_id, model_id: vars.model_id,
effort: vars.effort, effort: vars.effort,
plan_mode: vars.plan_mode,
}); });
if (stream.isLoading) { if (stream.isLoading) {
@ -64,14 +65,16 @@ export function useSubmitAgentMessage(threadId: string) {
} }
} }
const config = (!vars.model_id || !vars.effort) const configurable: Record<string, unknown> = {};
? undefined if (vars.model_id && vars.effort) {
: { configurable.agent_model_id = vars.model_id;
configurable: { configurable.agent_effort = vars.effort;
agent_model_id: vars.model_id, }
agent_effort: vars.effort, if (vars.plan_mode) {
}, configurable.plan_mode = true;
}; }
const config =
Object.keys(configurable).length > 0 ? { configurable } : undefined;
// Don't await: `stream.submit` resolves only when the run *finishes*, so // Don't await: `stream.submit` resolves only when the run *finishes*, so
// awaiting would keep the mutation `isPending` (and the prompt bar // awaiting would keep the mutation `isPending` (and the prompt bar

View file

@ -225,6 +225,7 @@ export interface SendAgentMessageVariables {
images?: Array<ImageChunk> images?: Array<ImageChunk>
model_id?: string | null model_id?: string | null
effort?: string | null effort?: string | null
plan_mode?: boolean
} }
export function useCancelAgentThread(threadId: string) { export function useCancelAgentThread(threadId: string) {

View file

@ -180,6 +180,8 @@ export interface AgentThread {
branch: string branch: string
model: string model: string
effort?: string | null effort?: string | null
planMode?: boolean
planStatus?: string | null
source?: AgentSource source?: AgentSource
status: AgentStatus status: AgentStatus
viewed: boolean viewed: boolean

111
ui/src/lib/plan.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* Client for the plan-review API + the Yjs collaboration endpoint.
*
* The plan document and its comment threads sync over a y-websocket connection;
* approve/reject post the client-harvested comments so the agent receives them
* as the instruction for the follow-up run.
*/
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
/\/$/,
""
)
function apiBase(): string {
if (API_BASE) return API_BASE
return typeof window !== "undefined" ? window.location.origin : ""
}
export interface PlanUser {
id: string
login: string
email: string | null
name: string
}
export type PlanStatus =
| "planning"
| "ready"
| "revising"
| "approved"
| "cancelled"
export interface PlanData {
threadId: string
status: PlanStatus
markdown: string
isOwner: boolean
user: PlanUser
}
export interface HarvestedComment {
author: string
body: string
quote?: string
resolved: boolean
}
export class PlanApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message)
this.name = "PlanApiError"
}
}
async function req<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${apiBase()}/dashboard/api${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
})
if (!res.ok) {
let message = res.statusText
try {
const body = await res.json()
if (body?.detail)
message =
typeof body.detail === "string"
? body.detail
: JSON.stringify(body.detail)
} catch {
/* ignore */
}
throw new PlanApiError(res.status, message)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export function getPlan(threadId: string): Promise<PlanData> {
return req<PlanData>(`/plan/${encodeURIComponent(threadId)}`)
}
export function approvePlan(
threadId: string,
comments: Array<HarvestedComment>
): Promise<{ status: string }> {
return req(`/plan/${encodeURIComponent(threadId)}/approve`, {
method: "POST",
body: JSON.stringify({ comments }),
})
}
export function rejectPlan(
threadId: string,
comments: Array<HarvestedComment>
): Promise<{ status: string }> {
return req(`/plan/${encodeURIComponent(threadId)}/reject`, {
method: "POST",
body: JSON.stringify({ comments }),
})
}
/** Base URL the y-websocket provider connects to; it appends `/<threadId>`. */
export function planCollabUrl(): string {
const base = apiBase()
const wsBase = base.replace(/^http/, "ws")
return `${wsBase}/dashboard/api/plan/yjs`
}

View file

@ -29,6 +29,7 @@ import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/auto
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner' import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new' import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId' import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
import { Route as AgentsThreadIdPlanRouteImport } from './routes/agents/$threadId_.plan'
import { Route as AgentsReviewsOwnerRepoNumberRouteImport } from './routes/agents/reviews/$owner.$repo.$number' import { Route as AgentsReviewsOwnerRepoNumberRouteImport } from './routes/agents/reviews/$owner.$repo.$number'
const UsageRoute = UsageRouteImport.update({ const UsageRoute = UsageRouteImport.update({
@ -132,6 +133,11 @@ const AgentsAutomationsScheduleIdRoute =
path: '/automations/$scheduleId', path: '/automations/$scheduleId',
getParentRoute: () => AgentsRoute, getParentRoute: () => AgentsRoute,
} as any) } as any)
const AgentsThreadIdPlanRoute = AgentsThreadIdPlanRouteImport.update({
id: '/$threadId_/plan',
path: '/$threadId/plan',
getParentRoute: () => AgentsRoute,
} as any)
const AgentsReviewsOwnerRepoNumberRoute = const AgentsReviewsOwnerRepoNumberRoute =
AgentsReviewsOwnerRepoNumberRouteImport.update({ AgentsReviewsOwnerRepoNumberRouteImport.update({
id: '/reviews/$owner/$repo/$number', id: '/reviews/$owner/$repo/$number',
@ -155,6 +161,7 @@ export interface FileRoutesByFullPath {
'/agents/instructions': typeof AgentsInstructionsRoute '/agents/instructions': typeof AgentsInstructionsRoute
'/review/styles': typeof ReviewStylesRoute '/review/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute '/agents/': typeof AgentsIndexRoute
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute '/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute '/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
@ -177,6 +184,7 @@ export interface FileRoutesByTo {
'/agents/instructions': typeof AgentsInstructionsRoute '/agents/instructions': typeof AgentsInstructionsRoute
'/review/styles': typeof ReviewStylesRoute '/review/styles': typeof ReviewStylesRoute
'/agents': typeof AgentsIndexRoute '/agents': typeof AgentsIndexRoute
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute '/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute '/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
@ -201,6 +209,7 @@ export interface FileRoutesById {
'/agents_/instructions': typeof AgentsInstructionsRoute '/agents_/instructions': typeof AgentsInstructionsRoute
'/review_/styles': typeof ReviewStylesRoute '/review_/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute '/agents/': typeof AgentsIndexRoute
'/agents/$threadId_/plan': typeof AgentsThreadIdPlanRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute '/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute '/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
@ -226,6 +235,7 @@ export interface FileRouteTypes {
| '/agents/instructions' | '/agents/instructions'
| '/review/styles' | '/review/styles'
| '/agents/' | '/agents/'
| '/agents/$threadId/plan'
| '/agents/automations/$scheduleId' | '/agents/automations/$scheduleId'
| '/agents/automations/new' | '/agents/automations/new'
| '/review/repositories/$owner' | '/review/repositories/$owner'
@ -248,6 +258,7 @@ export interface FileRouteTypes {
| '/agents/instructions' | '/agents/instructions'
| '/review/styles' | '/review/styles'
| '/agents' | '/agents'
| '/agents/$threadId/plan'
| '/agents/automations/$scheduleId' | '/agents/automations/$scheduleId'
| '/agents/automations/new' | '/agents/automations/new'
| '/review/repositories/$owner' | '/review/repositories/$owner'
@ -271,6 +282,7 @@ export interface FileRouteTypes {
| '/agents_/instructions' | '/agents_/instructions'
| '/review_/styles' | '/review_/styles'
| '/agents/' | '/agents/'
| '/agents/$threadId_/plan'
| '/agents/automations/$scheduleId' | '/agents/automations/$scheduleId'
| '/agents/automations/new' | '/agents/automations/new'
| '/review_/repositories/$owner' | '/review_/repositories/$owner'
@ -437,6 +449,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentsAutomationsScheduleIdRouteImport preLoaderRoute: typeof AgentsAutomationsScheduleIdRouteImport
parentRoute: typeof AgentsRoute parentRoute: typeof AgentsRoute
} }
'/agents/$threadId_/plan': {
id: '/agents/$threadId_/plan'
path: '/$threadId/plan'
fullPath: '/agents/$threadId/plan'
preLoaderRoute: typeof AgentsThreadIdPlanRouteImport
parentRoute: typeof AgentsRoute
}
'/agents/reviews/$owner/$repo/$number': { '/agents/reviews/$owner/$repo/$number': {
id: '/agents/reviews/$owner/$repo/$number' id: '/agents/reviews/$owner/$repo/$number'
path: '/reviews/$owner/$repo/$number' path: '/reviews/$owner/$repo/$number'
@ -451,6 +470,7 @@ interface AgentsRouteChildren {
AgentsThreadIdRoute: typeof AgentsThreadIdRoute AgentsThreadIdRoute: typeof AgentsThreadIdRoute
AgentsThreadsRoute: typeof AgentsThreadsRoute AgentsThreadsRoute: typeof AgentsThreadsRoute
AgentsIndexRoute: typeof AgentsIndexRoute AgentsIndexRoute: typeof AgentsIndexRoute
AgentsThreadIdPlanRoute: typeof AgentsThreadIdPlanRoute
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute
@ -462,6 +482,7 @@ const AgentsRouteChildren: AgentsRouteChildren = {
AgentsThreadIdRoute: AgentsThreadIdRoute, AgentsThreadIdRoute: AgentsThreadIdRoute,
AgentsThreadsRoute: AgentsThreadsRoute, AgentsThreadsRoute: AgentsThreadsRoute,
AgentsIndexRoute: AgentsIndexRoute, AgentsIndexRoute: AgentsIndexRoute,
AgentsThreadIdPlanRoute: AgentsThreadIdPlanRoute,
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute, AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute, AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute, AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute,

View file

@ -0,0 +1,109 @@
import { Link, createFileRoute } from "@tanstack/react-router"
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { ArrowLeft } from "lucide-react"
import { PlanReview } from "@/components/agents/PlanReview"
import { Skeleton } from "@/components/ui/skeleton"
import { PlanApiError, getPlan } from "@/lib/plan"
export const Route = createFileRoute("/agents/$threadId_/plan")({
component: PlanPage,
})
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-w-0 flex-1 items-center justify-center p-6">
{children}
</div>
)
}
function BackLink({ threadId }: { threadId: string }) {
return (
<Link
to="/agents/$threadId"
params={{ threadId }}
className="inline-flex items-center gap-1 text-xs text-[var(--ui-text-dim)] hover:text-[var(--ui-text)]"
>
<ArrowLeft className="size-3.5" />
Back to conversation
</Link>
)
}
function PlanPage() {
const { threadId } = Route.useParams()
// BlockNote + Yjs are browser-only; mount client-side before rendering.
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const query = useQuery({
queryKey: ["plan", threadId],
queryFn: () => getPlan(threadId),
// The agent shares the link as soon as it enters plan mode, before the plan
// is written — poll until the plan content is published.
refetchInterval: (q) => (q.state.data?.markdown ? false : 2000),
retry: (count, error) =>
!(
error instanceof PlanApiError &&
(error.status === 401 || error.status === 404)
) && count < 3,
})
if (!mounted || query.isLoading) {
return (
<Centered>
<Skeleton className="h-48 w-full max-w-2xl" />
</Centered>
)
}
if (query.isError) {
const status = query.error instanceof PlanApiError ? query.error.status : 0
return (
<Centered>
<div className="space-y-3 text-center text-sm text-[var(--ui-text-dim)]">
<p>
{status === 401
? "Please sign in to view this plan."
: "This plan could not be found."}
</p>
<BackLink threadId={threadId} />
</div>
</Centered>
)
}
const plan = query.data
if (!plan) {
return (
<Centered>
<Skeleton className="h-48 w-full max-w-2xl" />
</Centered>
)
}
if (!plan.markdown.trim()) {
return (
<Centered>
<div className="space-y-3 text-center text-sm text-[var(--ui-text-dim)]">
<p>
The agent is still writing the plan. This page will update
automatically…
</p>
<BackLink threadId={threadId} />
</div>
</Centered>
)
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[var(--ui-border)] px-6 pt-3">
<BackLink threadId={threadId} />
</div>
<PlanReview plan={plan} />
</div>
)
}

View file

@ -1,4 +1,5 @@
import { defineConfig } from "vite" import http from "node:http"
import { defineConfig, type Plugin } from "vite"
import { devtools } from "@tanstack/devtools-vite" import { devtools } from "@tanstack/devtools-vite"
import { tanstackStart } from "@tanstack/react-start/plugin/vite" import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import viteReact from "@vitejs/plugin-react" import viteReact from "@vitejs/plugin-react"
@ -7,10 +8,77 @@ import tailwindcss from "@tailwindcss/vite"
import { nitro } from "nitro/vite" import { nitro } from "nitro/vite"
import { VitePWA } from "vite-plugin-pwa" import { VitePWA } from "vite-plugin-pwa"
// Dev-only: when E2E_HARNESS is set (the `dev:mock` local harness) serve the app
// and the harness from one origin by proxying the API routes + the Yjs collab
// WebSocket to the harness. Same-origin keeps the session cookie on the WS, which
// the plan-review collab requires. Inert in production (E2E_HARNESS unset).
function mockHarnessProxy(): Plugin | null {
const target = process.env.E2E_HARNESS
if (!target) return null
const prefixes = [
"/dashboard/api",
"/webhooks",
"/mock",
"/control",
"/fake-gh",
"/fake-slack",
"/static",
"/ok",
]
const matches = (url?: string): boolean =>
!!url &&
prefixes.some(
(p) => url === p || url.startsWith(`${p}/`) || url.startsWith(`${p}?`)
)
const upstream = new URL(target)
return {
name: "mock-harness-proxy",
enforce: "pre",
async configureServer(server) {
const { createProxyServer } = await import("httpxy")
const proxy = createProxyServer({ target })
proxy.on("error", () => {})
server.middlewares.use((req, res, next) => {
if (matches(req.url)) void proxy.web(req, res).catch(() => {})
else next()
})
// Proxy the Yjs WebSocket by hand (httpxy's ws upgrade is unreliable here).
// Only claim our paths; Vite's own HMR socket upgrade is left untouched.
server.httpServer?.on("upgrade", (req, socket, head) => {
if (!matches(req.url)) return
const proxyReq = http.request({
host: upstream.hostname,
port: upstream.port,
method: "GET",
path: req.url,
headers: req.headers,
})
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
const lines = Object.entries(proxyRes.headers)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`)
.join("\r\n")
socket.write(
`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n${lines}\r\n\r\n`
)
if (proxyHead?.length) socket.write(proxyHead)
if (head?.length) proxySocket.write(head)
proxySocket.on("error", () => socket.destroy())
socket.on("error", () => proxySocket.destroy())
proxySocket.pipe(socket)
socket.pipe(proxySocket)
})
proxyReq.on("error", () => socket.destroy())
proxyReq.end()
})
},
}
}
const config = defineConfig({ const config = defineConfig({
optimizeDeps: { include: ["workbox-window"] }, optimizeDeps: { include: ["workbox-window"] },
worker: { format: "es" }, worker: { format: "es" },
plugins: [ plugins: [
mockHarnessProxy(),
devtools(), devtools(),
nitro(), nitro(),
viteTsConfigPaths({ viteTsConfigPaths({

115
uv.lock generated
View file

@ -2010,6 +2010,8 @@ dependencies = [
{ name = "langgraph-sdk" }, { name = "langgraph-sdk" },
{ name = "langsmith" }, { name = "langsmith" },
{ name = "markdownify" }, { name = "markdownify" },
{ name = "pycrdt" },
{ name = "pycrdt-websocket" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
@ -2044,6 +2046,8 @@ requires-dist = [
{ name = "langgraph-sdk", specifier = ">=0.4.2" }, { name = "langgraph-sdk", specifier = ">=0.4.2" },
{ name = "langsmith", specifier = "==0.8.18" }, { name = "langsmith", specifier = "==0.8.18" },
{ name = "markdownify", specifier = ">=1.2.2" }, { name = "markdownify", specifier = ">=1.2.2" },
{ name = "pycrdt", specifier = ">=0.14.1,<0.15" },
{ name = "pycrdt-websocket", specifier = ">=0.16.3,<0.17" },
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
{ name = "pyjwt", specifier = ">=2.12.1" }, { name = "pyjwt", specifier = ">=2.12.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
@ -2481,6 +2485,104 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]]
name = "pycrdt"
version = "0.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/4b/827ec39efadd52cf9dca9a663e7a5e215a4415a3ddd1b187536e21238f99/pycrdt-0.14.1.tar.gz", hash = "sha256:56e7c2d36407716f4977b5f3b972892c51758d31dafff9d043702b5c7fd1aa87", size = 95362, upload-time = "2026-06-17T14:18:04.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/2f/ed28aa8a47ad152e18e171176a049084f852c70c740a8378a4ade5740f4c/pycrdt-0.14.1-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:444a26e13c369d1e61062c523de3ea8532c6e699932a504cd84368434b761132", size = 1931105, upload-time = "2026-06-17T14:16:16.656Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fe/effa6f6fab73473f2e86af459c69779da6d4de3a080b246f53ed78424418/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e9bc0aa1af974f207772230a8213c7e3da48b0d694d7849533936408e071e92", size = 1048952, upload-time = "2026-06-17T14:16:18.122Z" },
{ url = "https://files.pythonhosted.org/packages/19/07/119fdc7000c4465e73bf00d7dab498300fa3d1a900929ec07d0362e22fd0/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90142ecf27f0f318fed6049a84d48da8eb7d76bc031799be424e3501c3b45f6c", size = 1068708, upload-time = "2026-06-17T14:16:22.193Z" },
{ url = "https://files.pythonhosted.org/packages/77/61/7eea3f81cc70f24e6148c51b8dc24f3fdd288ce12c6808c1a3ebf1b1118d/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b2cb83a5519a6421d6891b1ffbcd26245125aa9a66cec891c385d6f16c1a491", size = 1253482, upload-time = "2026-06-17T14:16:23.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/0e/8f6a7c21721af39da5df4c053444e4e17e1c28fa66c04b0ec1eb3db63164/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1068ca7466009058cc33ee0d65410c1a6c09dae13aa9471d22f8d0d9a586f466", size = 1103057, upload-time = "2026-06-17T14:16:25.193Z" },
{ url = "https://files.pythonhosted.org/packages/0c/73/43f9b51ab818ccf83d3a8a1ba0e4adb23701aab8ee8a97037ce9d916c746/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48d6845b72d88dfbbf1ff9927b5070ea09c7d4eec9b8c498eeb1b5083f514b79", size = 1074008, upload-time = "2026-06-17T14:16:26.827Z" },
{ url = "https://files.pythonhosted.org/packages/40/95/785ba888d26886e5f468760123d3119086f8de5ea760725eea3a00d3c285/pycrdt-0.14.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8a50a1a5a934d45414bffce04c2e447587a7031dd650b92100364b6b4807e26", size = 1171836, upload-time = "2026-06-17T14:16:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/18/4e/407bd9fcb8520675c2fcc9047e0df53962a78f1be1e1baf91db7362364f7/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b35a8da35b6dc690088e78be9990e6628a5693ecbe80a0645d2be004dec469c7", size = 1223056, upload-time = "2026-06-17T14:16:29.881Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c2/95ff0a5d390ee2d91eb953d0c7ee7fa4c7ce10dac21d3d6d895c9212781f/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9a4ce8e1646b01aba9c288b58b870a72e1fbbb287ce76636b73158ebf7434b47", size = 1341061, upload-time = "2026-06-17T14:16:31.445Z" },
{ url = "https://files.pythonhosted.org/packages/b8/cc/853994459b08ec21cfb1901c864707778036840514db4c088ee8f2e964e7/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bfa85fbe2cea468b23b79361acc45f810dd2e835859b0f3513796ab178cf027b", size = 1339013, upload-time = "2026-06-17T14:16:33.007Z" },
{ url = "https://files.pythonhosted.org/packages/18/10/f97044ae2d958d196d254ed9df6e72b6f4e34a7d3cdc9533827df3795ce8/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:98e0d92ce0ad441dbbe943122c7891a43b34ceb6360f5cfde803e9c9da3272f4", size = 1286805, upload-time = "2026-06-17T14:16:35.077Z" },
{ url = "https://files.pythonhosted.org/packages/90/3d/2a74498c213660a4048c446000f7609d4046140271b84be007a8f6f36aa2/pycrdt-0.14.1-cp311-cp311-win32.whl", hash = "sha256:f140d6dba639b7af665ad178f1e8be7723ef616dd9887a75851a9fb2d1fd5053", size = 802987, upload-time = "2026-06-17T14:16:36.582Z" },
{ url = "https://files.pythonhosted.org/packages/cf/7c/c66dfcbf37f813ab7b983bc730c7c4dd44582803dee0fabf567b59cf6eef/pycrdt-0.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:c593d61e1ee90d23677883372b59cf7b5e188f6070d77eb80e67379dbd54b053", size = 865483, upload-time = "2026-06-17T14:16:39.599Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d1/a61cb33ccaca7d5ede9f59bf16c4d4e254e57dbc1d306cf7c92cdeb56bf0/pycrdt-0.14.1-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:35a3f4bbad8b5848c653997a9cb2bc8df4c6cad8aaea55e9261d23d71a574e55", size = 1920341, upload-time = "2026-06-17T14:16:41.184Z" },
{ url = "https://files.pythonhosted.org/packages/0d/01/abbf4134d7353690a6ebece3beace80ec581c67deb2e264957ec9da85241/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53ea32083193b8740c5409795087254dd01c4df65aca6edf1f83ceda3fc2980d", size = 1039888, upload-time = "2026-06-17T14:16:42.92Z" },
{ url = "https://files.pythonhosted.org/packages/2d/67/1adbb5acbc784200501851ec56d7d64b6110fa1e9b9e3dfd67c29ae1b737/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f301fee6960320a64075494e1ac25ba6da5e3984e126c13f9e3f890043cac0f", size = 1061004, upload-time = "2026-06-17T14:16:44.554Z" },
{ url = "https://files.pythonhosted.org/packages/2b/83/0e9b77b867f03cd5040b0508f05071c8903af43801e1232cfd13ff0e16c6/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b9c99f44b3d89e76cd2edda5fd0ad74e03dd02abbe1524feff939909ff29e91", size = 1246302, upload-time = "2026-06-17T14:16:46.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/3a/956e915b3d9546869a7136564d1b9a122b354b45c1264efbc0ddb4ca31eb/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12ea055f21596320f00f181ec8d082049a4157703b07594ee4f6b5195e15d6c8", size = 1096588, upload-time = "2026-06-17T14:16:48.404Z" },
{ url = "https://files.pythonhosted.org/packages/77/53/b30626fdb8dc635728b2bc3860eb0ae6966fd96595215abe09efd7f978cc/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62219393c1c9aa60a00ac7a0da574746edd62f571a83aca05b1632ceabefa4db", size = 1069006, upload-time = "2026-06-17T14:16:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/c6/7b/18efd6c541112c962515d1f181761d10953aaae3ab07083d6924af8a2f30/pycrdt-0.14.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a6abaede60fb9d2acf0dd8ced85d0d328da7a5a03823a25eb3203acc56d3d6b", size = 1163604, upload-time = "2026-06-17T14:16:51.545Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e8/e0299e9e8173dcf0f001ffc2ae6f8cf25b39419ef1db65b7e26623fc8b5a/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a669c1a3ffb621c5034ce08b562196fd1d2d02b138e2cfb39345dfc16bc9d59", size = 1214943, upload-time = "2026-06-17T14:16:53.239Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b3/7aad8b7a9797ad98ea1b2d70021a44b01af4cbea2d0cc37beb17499dd994/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ca57f015894aa3b560d73b6f48d5455a61e69bcde30601295409924f47724195", size = 1337232, upload-time = "2026-06-17T14:16:54.874Z" },
{ url = "https://files.pythonhosted.org/packages/a6/22/ff4a93fc6b92f4173e11ce7ebce57f45b979d35da3a3e3a6899bdb9da362/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f253e6a9ac9e9487c7d69e078e8904918f1f356e6e9565564e36cefd44faa055", size = 1332373, upload-time = "2026-06-17T14:16:56.439Z" },
{ url = "https://files.pythonhosted.org/packages/c0/40/eae0b6d990577fa5b59145783d117d8984b1a9540d4ba037905f4824b9f2/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5a07a3c3b8424458793805abf9592d078dec454f9bc345e71e0d0fb788d21f", size = 1282048, upload-time = "2026-06-17T14:16:58.125Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a9/e97e637ab9703cb4d951f3ff1e62641db0ca2517f842837598eaaf3667ff/pycrdt-0.14.1-cp312-cp312-win32.whl", hash = "sha256:a638ed29386eec9b202114cbab3f35ad89d8a44c7883d9ee84cf90761dd8ae52", size = 798254, upload-time = "2026-06-17T14:17:00.201Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b2/5cdc49f8a021732a1a7070b8cbec2073ca5045fbf6c0b5718c3e283d254e/pycrdt-0.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7e6876eda749d79f15c2dcc10316f08f1f47756bb25631341a3314e7356ed41", size = 863716, upload-time = "2026-06-17T14:17:02.033Z" },
{ url = "https://files.pythonhosted.org/packages/a6/17/d970446b932d614445dca61739f592fdd328ed435dd54e67b5c804a31ef7/pycrdt-0.14.1-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5371deb5d40d3c9ddd5757adb5556ea8566b2fb46c1553ad313e9a5dfc84154e", size = 1919734, upload-time = "2026-06-17T14:17:03.806Z" },
{ url = "https://files.pythonhosted.org/packages/a6/d4/9ad8ee06131ee9a8b3366698bd68b39ebdc59764f68c436b61e4f18f7278/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbadc42dd0a6c3869859cf8900b59b21e8160e415a95e85d82e40cc11c887660", size = 1039641, upload-time = "2026-06-17T14:17:05.601Z" },
{ url = "https://files.pythonhosted.org/packages/66/5c/2828a1414478e1df6c51289962bf483a55377a888439d92fc8c6e6699f8d/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7770624c7003198312646cea57bb0c4d7a231ef57b1975f4487dd7d790fa1196", size = 1061111, upload-time = "2026-06-17T14:17:07.369Z" },
{ url = "https://files.pythonhosted.org/packages/c6/02/171aa04758453db0e976b3aaf87de7dbd8f3da6145d3d48d40dbf1073eac/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c348b780da2792d6cdc2e4dc237b6e8d7f87fe9b1fc2f24e467bf294f0f7581", size = 1246233, upload-time = "2026-06-17T14:17:08.988Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d8/1f186f211ba1e4fffc8124d21ae9547f876c2c9c1e2d834919d5c5569f75/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d28a206a061cb44b284a53587561028547c3a12c4a12408bd0e499f887753a5", size = 1096827, upload-time = "2026-06-17T14:17:10.663Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e5/5a027de04406e92282c29c3ec49e5a60c457255aac524cf8ed88064c124b/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35149ce7388788681c1d36115545b5c5ceda8d46982eb36f7ed0fab4379f8907", size = 1068703, upload-time = "2026-06-17T14:17:12.299Z" },
{ url = "https://files.pythonhosted.org/packages/01/08/2bc8005634e5f0c067cb9d942f0a2c628e88152f218a159a1e4aea51785f/pycrdt-0.14.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b708985f4aaddeb7d7bf7888fe65f49c91fa0b36ed09a78a1041cb888cd64b50", size = 1162543, upload-time = "2026-06-17T14:17:13.985Z" },
{ url = "https://files.pythonhosted.org/packages/3d/53/b10ef7aaadfd24d74ca7d9894af9b4c925eaed5a4ee3a098728d0ac81bc1/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eeeee3284e9c9dc2dfcc45db7c55445e70f17bb12015583bd388f6c8f901237c", size = 1214684, upload-time = "2026-06-17T14:17:15.701Z" },
{ url = "https://files.pythonhosted.org/packages/10/9c/19af353a9b2d407cf942f8bec752dc94a7e82d40fb039be34db9a91eba6c/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4e715421a47702dbf81d90a32213f6868567e751926629f60912b03af397bd9a", size = 1337139, upload-time = "2026-06-17T14:17:17.647Z" },
{ url = "https://files.pythonhosted.org/packages/ac/a4/e1bf0c56a9b6bae6a89cda3ac7ad2ada525c827693f531435a14922b859f/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:34288b0a97ccc38e1e87f55448b1c08734deb9ad3360042d4653084bee6a4f80", size = 1331647, upload-time = "2026-06-17T14:17:19.301Z" },
{ url = "https://files.pythonhosted.org/packages/15/04/30f05d4f277f76bfefd7d856592922abaf1d17983c0b084aa347a96ad580/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07fecd0e9275e336e46f81203778ab811ee97c7272dfd7937b66844af6fcc13b", size = 1281303, upload-time = "2026-06-17T14:17:20.896Z" },
{ url = "https://files.pythonhosted.org/packages/da/5b/5b05f492e36f31b3e4be062675750b049ad86dbce64c5197dab660f70988/pycrdt-0.14.1-cp313-cp313-win32.whl", hash = "sha256:3b8324b7fb6fb31b2b55eb8dfeedf2e0a40d2e7143c6011d4b63ee102b44f3de", size = 798158, upload-time = "2026-06-17T14:17:22.736Z" },
{ url = "https://files.pythonhosted.org/packages/e7/06/e0f436b1b769dc8befd25e27dd133fb1690eba3e82734cd173aa95609340/pycrdt-0.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1bfb808acae8e97e5725086e334c4b5dc79d693962080abf1ab97e70d7400ba1", size = 863084, upload-time = "2026-06-17T14:17:24.437Z" },
{ url = "https://files.pythonhosted.org/packages/0e/22/4dec10d664f3f4d09e1142504ad354f993919c9a9ad9a7bb9d3b2eb277b2/pycrdt-0.14.1-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:008b67d78cc8a99a07c2bbe876a866c5d4ef7e3a90cf33463af7521fa5837a6c", size = 1931551, upload-time = "2026-06-17T14:17:26.19Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ec/42e16b63a70b1907301945ea3a9e85cbfad0a4a1cfd669367e02f28a8e0f/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389c8dda4a41a1d6d664ab5c4c66d859e75d24add7e2f5df8ce6954effbe23c6", size = 1041602, upload-time = "2026-06-17T14:17:27.974Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/ba53f5f3ebd5a104b431a4d3433dba9770bded5ece259e582530e289f89b/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b89eb0020711b2faaec41bd701af64b7c201ef039e781fe5fc945d148301fde", size = 1061937, upload-time = "2026-06-17T14:17:29.755Z" },
{ url = "https://files.pythonhosted.org/packages/88/86/6127c295d5c9fdfa13f61e569c2fb1cba3ce3bb1fbb7a912f14e3baac04a/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ba13fcb11665d05f3e3a80942dda42ff0d91a33f685909c2e90d0e57cacdad5", size = 1249379, upload-time = "2026-06-17T14:17:31.719Z" },
{ url = "https://files.pythonhosted.org/packages/46/63/fa06b2b8488788bf073d81fb8020eed5c8084465037c18478aaa91c06a8a/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a64e7a2e4ea5bc27f1675b64a3669c588adb6a8f2ed33a798d42d7d1f607845", size = 1099321, upload-time = "2026-06-17T14:17:33.547Z" },
{ url = "https://files.pythonhosted.org/packages/83/24/a19c5825f25135485527f2c83c4b4151ba6688a3fac7c7a0c8d20bd2b25f/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a153994b1f32ec6012d00ec645f80bf7e77ebea907759ee5fbe4e08a6f242bd7", size = 1069402, upload-time = "2026-06-17T14:17:35.295Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0a/f91f8689d5abb8e0d3fa73ee71e52326185913f27ac0189223d0b7bad77c/pycrdt-0.14.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa6f02d8272737e734033a7ec62382d3929b2ab70681aac54b9d21373d78bf82", size = 1165406, upload-time = "2026-06-17T14:17:37.264Z" },
{ url = "https://files.pythonhosted.org/packages/b2/59/829f7aab8706e26fd5876ff37371359ff2930211b534570cd3d11305d6f6/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e83047e92cf7248c2d40d7be7a42fd7065598c3cc31f3d422def2886499787d6", size = 1216928, upload-time = "2026-06-17T14:17:39.232Z" },
{ url = "https://files.pythonhosted.org/packages/19/8f/a69823e4b02f1da3329d82316b2c733b1875c96e3aff5a2c1ebd8cefd1f8/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9d33a6dd2dab10a6380ce9bd7fee79f7e3b868214e95e977842adf9e54906fdf", size = 1337450, upload-time = "2026-06-17T14:17:40.991Z" },
{ url = "https://files.pythonhosted.org/packages/30/5d/040bbcaf59a0e590e3f89946afa611960d08f87c60367260a3ecf01c02de/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e347942c2ac71c1ac6ef04212fcd3eb8456f88053b71bf51a288084531c27135", size = 1334972, upload-time = "2026-06-17T14:17:42.6Z" },
{ url = "https://files.pythonhosted.org/packages/67/e4/be739b1301575b04e85eab7d76cb557a9abd167451d7442592e3f3d65b95/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cce1a249ab5b3d9757ceb42f22da51043c50f1d5c18b0673522b41a7311ce232", size = 1280543, upload-time = "2026-06-17T14:17:45.715Z" },
{ url = "https://files.pythonhosted.org/packages/18/06/1760c307048b431a195b41027faf48906dc8f22b8ee647817d6a15258c9c/pycrdt-0.14.1-cp314-cp314-win32.whl", hash = "sha256:fb93e7e277fc9b2cc26ce5b51d3e3b6436a762d0a0985ceaa50459ff6a33230f", size = 800359, upload-time = "2026-06-17T14:17:47.843Z" },
{ url = "https://files.pythonhosted.org/packages/5c/5c/ef3db9970413a661680972274f6b93f961130748e581a41c0c9960e29275/pycrdt-0.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:e3334b8f9fc8caeb41ac8a754adeb9dc640bb8fa1420c38cdb8dc2001a4777c8", size = 864167, upload-time = "2026-06-17T14:17:49.65Z" },
{ url = "https://files.pythonhosted.org/packages/a3/f0/33cf0c68c70e541e26a08b8d6eede09e26b134b87a9f825337b28b698206/pycrdt-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d814be8a83cf15238b7c5b8b4c6c775234f1254aea61b17cf9d71dea40d9ca8e", size = 1935886, upload-time = "2026-06-17T14:17:51.486Z" },
{ url = "https://files.pythonhosted.org/packages/45/7f/7fdd3f5fbff47233db93488766995db52fa80c9b73af33fe85d102d8da97/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:827943404994a71177742b56b47cde92ec97cc4de9c816efc2d19772416b22d9", size = 1050174, upload-time = "2026-06-17T14:17:53.397Z" },
{ url = "https://files.pythonhosted.org/packages/89/ad/b866000af356fef4f2f0271822698334bef7716166ed27a65de83ebdbb22/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7cc796cdea989f80057c07632667d44cacbe4c6001bd5d7910ef0487a5b971e", size = 1070987, upload-time = "2026-06-17T14:17:55.101Z" },
{ url = "https://files.pythonhosted.org/packages/4d/aa/5a93d3959e1cca6db70a10d0660bf6dee2deafac384c2b7e1171ff81198d/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd7b2e2e1b81e5fa5a550d835bee73b50cd48ccc999ed66b21900078d3e24027", size = 1261468, upload-time = "2026-06-17T14:17:57Z" },
{ url = "https://files.pythonhosted.org/packages/f0/10/3f6499c2fcc41fabc47d4e5fb1e292cc3f09e96a7b352685f6bfa09750b2/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c41f0487f2c913eb8c1835d0c386e322c482124e1b9b0b90d69976808501f05", size = 1104527, upload-time = "2026-06-17T14:17:59.074Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/1e70d8050741d96f32e138b6adb7148f7813def4b8f2404bd6f280668515/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7dfef69c501bf15cc7f7b6056ef9cab9c91bfc5d4e86f4ea979240947a75c50", size = 1075181, upload-time = "2026-06-17T14:18:00.983Z" },
{ url = "https://files.pythonhosted.org/packages/36/49/871a8a110fef882fd1a4f330d8ae06a42f7cbe56b4258de810dbf5bb881c/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18ea987fd4346f28463fe80acf8c94658227ad631852e3419eb421bbd90a5f59", size = 1176188, upload-time = "2026-06-17T14:18:02.773Z" },
]
[[package]]
name = "pycrdt-store"
version = "0.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "pycrdt" },
{ name = "sqlite-anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/aa/e6d175db0106b72dcfe33d8cd718f9625e34c1d9cd0d3852052b75e9aab8/pycrdt_store-0.1.5.tar.gz", hash = "sha256:10cb0a71b86e167735235b3ab82816015ba746a2e660bcb133adb67a8d5c8818", size = 14845, upload-time = "2026-06-11T07:42:01.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/65/2edb1bb1cdce06df2aa2dcf617594344b5fc7f823d0770db2647e0306010/pycrdt_store-0.1.5-py3-none-any.whl", hash = "sha256:5ef6978d363be01275ffc337567072050363fa18fd6a51f0a48917dc210025f2", size = 11840, upload-time = "2026-06-11T07:42:00.827Z" },
]
[[package]]
name = "pycrdt-websocket"
version = "0.16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "pycrdt" },
{ name = "pycrdt-store" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/0f/e9a9f3b298d6e6b6abacf8a41ea7f4f697aa8116215a15c8674fa1445f39/pycrdt_websocket-0.16.3.tar.gz", hash = "sha256:dd2723d531d9ca7048a67dd8d27c63e4362f5d909ca8c97efcfb2bd1d15d8d2a", size = 23822, upload-time = "2026-06-11T09:03:43.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/c6/c4176d2093bce6304e9f34470aae7f733c3818d3e7b54bf31892f1b3429e/pycrdt_websocket-0.16.3-py3-none-any.whl", hash = "sha256:dc3585705d3c2060b24ec329bb7c2065dae9dc50eb7d85854dcf0ed018958c4b", size = 14906, upload-time = "2026-06-11T09:03:42.355Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@ -3149,6 +3251,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
] ]
[[package]]
name = "sqlite-anyio"
version = "0.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/a3/7830c95b37f1268dbb47e559d1f1ae027f3a4c36b1f7fc1b2dc5de1c5073/sqlite_anyio-0.2.8.tar.gz", hash = "sha256:d68b51a18c01a7dfa9cedbc319871ce77ab3ed0822518fb32810bb465b52d761", size = 3271, upload-time = "2026-03-02T10:37:43.466Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/aa/182981b92659df83c3eb7d6f8fb0874984d72ad688fa4054cb96bc044bb0/sqlite_anyio-0.2.8-py3-none-any.whl", hash = "sha256:bbdfefb144aed2633d2618ee1508edd3abe67a00389379360949da4671640d86", size = 4041, upload-time = "2026-03-02T10:37:42.246Z" },
]
[[package]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.3.4" version = "3.3.4"