diff --git a/apps/debug-frontend/.gitignore b/apps/debug-frontend/.gitignore new file mode 100644 index 000000000..aa2168ae1 --- /dev/null +++ b/apps/debug-frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.vitest-attachments +src/**/__screenshots__ diff --git a/apps/debug-frontend/.oxfmtignore b/apps/debug-frontend/.oxfmtignore new file mode 100644 index 000000000..928e4ae2f --- /dev/null +++ b/apps/debug-frontend/.oxfmtignore @@ -0,0 +1,2 @@ +dist +src/routeTree.gen.ts diff --git a/apps/debug-frontend/.oxlintignore b/apps/debug-frontend/.oxlintignore new file mode 100644 index 000000000..928e4ae2f --- /dev/null +++ b/apps/debug-frontend/.oxlintignore @@ -0,0 +1,2 @@ +dist +src/routeTree.gen.ts diff --git a/apps/debug-frontend/README.md b/apps/debug-frontend/README.md new file mode 100644 index 000000000..70e66e7ca --- /dev/null +++ b/apps/debug-frontend/README.md @@ -0,0 +1,34 @@ +# @plannotator/debug-frontend + +Debug/development harness UI for the Plannotator daemon runtime. **Not production code** — this is a +testbed for exercising daemon sessions, verifying event streams, and testing session lifecycle actions. + +## Shape + +- `src/routes` is only TanStack Router wiring. +- `src/daemon` owns the typed daemon API client and contracts. +- `src/sessions` owns session ids, session state, the dashboard, and mode dispatch. +- `src/plan`, `src/review`, `src/annotate`, `src/archive`, and `src/setup-goal` own product views. +- `src/testing` owns contract fixtures and browser helpers. + +The shell talks to session APIs through `/s/:sessionId/api`, never root `/api`. + +The build is intentionally single-file HTML for daemon serving. Separate static asset +routes are deferred until the full UI migration needs code splitting or cacheable chunks. + +## Commands + +```bash +bun run --cwd apps/debug-frontend dev +bun run --cwd apps/debug-frontend build +bun run --cwd apps/debug-frontend check +bun run --cwd apps/debug-frontend test:browser +``` + +Or from the repo root: + +```bash +bun run dev:debug-frontend +bun run build:debug-frontend +bun run check:debug-frontend +``` diff --git a/apps/debug-frontend/index.html b/apps/debug-frontend/index.html new file mode 100644 index 000000000..a975c0f7a --- /dev/null +++ b/apps/debug-frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Plannotator + + +
+ + + diff --git a/apps/debug-frontend/package.json b/apps/debug-frontend/package.json new file mode 100644 index 000000000..2bb66326e --- /dev/null +++ b/apps/debug-frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "@plannotator/debug-frontend", + "description": "Debug/development harness UI for the Plannotator daemon runtime. Not production code.", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && bun run scripts/verify-single-file-build.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "oxlint .", + "lint:fix": "oxlint . --fix", + "fmt": "oxfmt --ignore-path .oxfmtignore --write .", + "fmt:check": "oxfmt --ignore-path .oxfmtignore --check .", + "test": "vitest run", + "test:browser": "vitest run --config vitest.browser.config.ts", + "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test" + }, + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@tanstack/react-router": "^1.141.0", + "immer": "^10.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-plugin": "^1.141.0", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "@vitest/browser-playwright": "^4.0.16", + "oxfmt": "^0.17.0", + "oxlint": "^1.31.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + "vitest": "^4.0.16" + } +} diff --git a/apps/debug-frontend/scripts/verify-single-file-build.ts b/apps/debug-frontend/scripts/verify-single-file-build.ts new file mode 100644 index 000000000..137408b26 --- /dev/null +++ b/apps/debug-frontend/scripts/verify-single-file-build.ts @@ -0,0 +1,56 @@ +import { existsSync, readdirSync, readFileSync } from "fs"; +import { join, relative } from "path"; + +const distDir = join(import.meta.dirname, "..", "dist"); +const indexPath = join(distDir, "index.html"); + +function listFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files; +} + +if (!existsSync(indexPath)) { + throw new Error("Expected apps/debug-frontend/dist/index.html to exist after build."); +} + +const html = readFileSync(indexPath, "utf-8"); + +const outputFiles = listFiles(distDir) + .map((file) => relative(distDir, file)) + .sort(); +const extraFiles = outputFiles.filter((file) => file !== "index.html"); + +if (extraFiles.length > 0) { + throw new Error( + `Frontend daemon shell build must be single-file; found outputs: ${extraFiles.join(", ")}`, + ); +} + +const htmlWithoutInlineCode = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, ""); + +const externalScriptPattern = /]*\bsrc=["'][^"']+["']/i; +const externalLinkPatterns = [ + /]*\brel=["'](?:stylesheet|modulepreload|preload)["'][^>]*\bhref=["'][^"']+["']/i, + /]*\bhref=["'][^"']+["'][^>]*\brel=["'](?:stylesheet|modulepreload|preload)["']/i, +]; + +if ( + externalScriptPattern.test(html) || + externalLinkPatterns.some((pattern) => pattern.test(htmlWithoutInlineCode)) +) { + throw new Error("Frontend daemon shell build must inline scripts and styles."); +} + +console.log("Verified single-file frontend shell build."); diff --git a/apps/debug-frontend/src/annotate/AnnotateSessionView.tsx b/apps/debug-frontend/src/annotate/AnnotateSessionView.tsx new file mode 100644 index 000000000..7054a077a --- /dev/null +++ b/apps/debug-frontend/src/annotate/AnnotateSessionView.tsx @@ -0,0 +1,21 @@ +import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups"; +import { ApiGroupList } from "../shared/ui/ApiGroupList"; +import { SessionFacts } from "../shared/ui/SessionFacts"; +import type { SessionViewComponentProps } from "../sessions/session-view-registry"; + +export function AnnotateSessionView({ bootstrap }: SessionViewComponentProps) { + return ( +
+
+

Annotate

+

{bootstrap.session.label}

+

+ Skeleton for markdown, folder, last-message, raw HTML, URL annotation, review-gate + approval, linked docs, image attachments, drafts, and external annotations. +

+
+ + +
+ ); +} diff --git a/apps/debug-frontend/src/app/layout/ShellLayout.tsx b/apps/debug-frontend/src/app/layout/ShellLayout.tsx new file mode 100644 index 000000000..1d3d45cac --- /dev/null +++ b/apps/debug-frontend/src/app/layout/ShellLayout.tsx @@ -0,0 +1,22 @@ +import { Link, Outlet } from "@tanstack/react-router"; + +export function ShellLayout() { + return ( +
+
+
+

Local runtime shell

+

Plannotator

+
+ +
+
+ +
+
+ ); +} diff --git a/apps/debug-frontend/src/app/router.tsx b/apps/debug-frontend/src/app/router.tsx new file mode 100644 index 000000000..63b8e47d0 --- /dev/null +++ b/apps/debug-frontend/src/app/router.tsx @@ -0,0 +1,23 @@ +import { createRouter } from "@tanstack/react-router"; +import { createDaemonApiClient, type DaemonApiClient } from "../daemon/api/client"; +import { routeTree } from "../routeTree.gen"; + +export interface AppRouterContext { + daemonClient: DaemonApiClient; +} + +export function createAppRouter( + context: AppRouterContext = { daemonClient: createDaemonApiClient() }, +) { + return createRouter({ + routeTree, + context, + defaultPreload: "intent", + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/debug-frontend/src/app/state/shell-store.test.ts b/apps/debug-frontend/src/app/state/shell-store.test.ts new file mode 100644 index 000000000..3f9bcff02 --- /dev/null +++ b/apps/debug-frontend/src/app/state/shell-store.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { createShellStore } from "./shell-store"; + +describe("shell store", () => { + test("tracks shell panel and diagnostics state", () => { + const store = createShellStore(); + + store.getState().setActivePanel("diagnostics"); + store.getState().setCompactDensity(true); + store.getState().toggleDiagnostics(); + + expect(store.getState().activePanel).toBe("diagnostics"); + expect(store.getState().compactDensity).toBe(true); + expect(store.getState().diagnosticsOpen).toBe(true); + }); +}); diff --git a/apps/debug-frontend/src/app/state/shell-store.ts b/apps/debug-frontend/src/app/state/shell-store.ts new file mode 100644 index 000000000..a972219fb --- /dev/null +++ b/apps/debug-frontend/src/app/state/shell-store.ts @@ -0,0 +1,55 @@ +import { createStore } from "zustand/vanilla"; +import { useStore } from "zustand"; +import { immer } from "zustand/middleware/immer"; + +export type ShellPanel = "sessions" | "details" | "diagnostics"; + +export interface ShellStoreState { + activePanel: ShellPanel; + compactDensity: boolean; + diagnosticsOpen: boolean; +} + +export interface ShellStoreActions { + setActivePanel(panel: ShellPanel): void; + setCompactDensity(value: boolean): void; + toggleDiagnostics(): void; +} + +export type ShellStore = ShellStoreState & ShellStoreActions; + +const initialShellState: ShellStoreState = { + activePanel: "sessions", + compactDensity: false, + diagnosticsOpen: false, +}; + +export function createShellStore(initial: Partial = {}) { + return createStore()( + immer((set) => ({ + ...initialShellState, + ...initial, + setActivePanel(panel) { + set((state) => { + state.activePanel = panel; + }); + }, + setCompactDensity(value) { + set((state) => { + state.compactDensity = value; + }); + }, + toggleDiagnostics() { + set((state) => { + state.diagnosticsOpen = !state.diagnosticsOpen; + }); + }, + })), + ); +} + +export const shellStore = createShellStore(); + +export function useShellStore(selector: (state: ShellStore) => T): T { + return useStore(shellStore, selector); +} diff --git a/apps/debug-frontend/src/archive/ArchiveSessionView.tsx b/apps/debug-frontend/src/archive/ArchiveSessionView.tsx new file mode 100644 index 000000000..844529277 --- /dev/null +++ b/apps/debug-frontend/src/archive/ArchiveSessionView.tsx @@ -0,0 +1,21 @@ +import { apiGroupsForMode } from "../sessions/session-api-groups"; +import { ApiGroupList } from "../shared/ui/ApiGroupList"; +import { SessionFacts } from "../shared/ui/SessionFacts"; +import type { SessionViewComponentProps } from "../sessions/session-view-registry"; + +export function ArchiveSessionView({ bootstrap }: SessionViewComponentProps) { + return ( +
+
+

Archive

+

{bootstrap.session.label}

+

+ Skeleton for browsing saved plan decisions, inspecting approved and denied plans, and + closing the read-only archive session. +

+
+ + +
+ ); +} diff --git a/apps/debug-frontend/src/daemon/api/client.test.ts b/apps/debug-frontend/src/daemon/api/client.test.ts new file mode 100644 index 000000000..ab9f25509 --- /dev/null +++ b/apps/debug-frontend/src/daemon/api/client.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "vitest"; +import { createDaemonErrorResponse } from "@plannotator/shared/daemon-protocol"; +import { sessionBootstrap, sessionListFixture } from "../../testing/fixtures/daemon"; +import { createFixtureFetch } from "../../testing/fetch"; +import { createDaemonApiClient } from "./client"; + +describe("daemon API client", () => { + test("loads daemon session bootstrap through the session-scoped API base", async () => { + const bootstrap = sessionBootstrap("plan", 1); + const fixture = createFixtureFetch({ + "/s/plan-session-1/api/session": bootstrap, + }); + const client = createDaemonApiClient({ fetch: fixture.fetch }); + + const result = await client.getSessionBootstrap("plan-session-1"); + + expect(result.ok).toBe(true); + expect(result.ok && result.data.apiBase).toBe("/s/plan-session-1/api"); + expect(fixture.requests).toEqual([{ url: "/s/plan-session-1/api/session", init: undefined }]); + expect(fixture.requests[0].url).not.toBe("/api/session"); + }); + + test("lists sessions from the daemon control plane", async () => { + const fixture = createFixtureFetch({ + "/daemon/sessions?clean=1": sessionListFixture, + }); + const client = createDaemonApiClient({ fetch: fixture.fetch }); + + const result = await client.listSessions({ clean: true }); + + expect(result.ok).toBe(true); + expect(result.ok && result.data.sessions).toHaveLength(5); + }); + + test("builds daemon event and session-scoped API URLs", () => { + const client = createDaemonApiClient({ baseUrl: "http://127.0.0.1:19432/root/" }); + + expect(client.getEventsUrl()).toBe("http://127.0.0.1:19432/daemon/events"); + expect(client.getSessionApiUrl("plan-session_1", "/api/plan")).toBe( + "http://127.0.0.1:19432/s/plan-session_1/api/plan", + ); + }); + + test("runs debug session actions through the session-scoped API", async () => { + const fixture = createFixtureFetch({ + "/s/review-session-1/api/feedback": { ok: true }, + }); + const client = createDaemonApiClient({ fetch: fixture.fetch }); + + const result = await client.runSessionAction( + { + id: "review-session-1", + mode: "review", + status: "active", + url: "http://127.0.0.1/s/review-session-1", + project: "plannotator", + label: "Review", + createdAt: "2026-05-17T00:00:00.000Z", + updatedAt: "2026-05-17T00:00:00.000Z", + }, + "review-approve", + ); + + expect(result.ok).toBe(true); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].url).toBe("/s/review-session-1/api/feedback"); + expect(fixture.requests[0].init?.method).toBe("POST"); + expect(JSON.parse(String(fixture.requests[0].init?.body))).toMatchObject({ + approved: true, + feedback: "LGTM", + annotations: [], + }); + }); + + test("normalizes daemon error responses", async () => { + const fixture = createFixtureFetch({ + "/s/missing-session/api/session": Response.json( + createDaemonErrorResponse("session-not-found", "Session not found."), + { status: 404 }, + ), + }); + const client = createDaemonApiClient({ fetch: fixture.fetch }); + + const result = await client.getSessionBootstrap("missing-session"); + + expect(result.ok).toBe(false); + expect(!result.ok && result.error.kind).toBe("daemon-error"); + expect(!result.ok && result.error.message).toBe("Session not found."); + }); + + test("normalizes malformed JSON", async () => { + const fixture = createFixtureFetch({ + "/daemon/sessions": new Response("not json", { status: 200 }), + }); + const client = createDaemonApiClient({ fetch: fixture.fetch }); + + const result = await client.listSessions(); + + expect(result.ok).toBe(false); + expect(!result.ok && result.error.kind).toBe("invalid-json"); + }); + + test("normalizes network failures", async () => { + const client = createDaemonApiClient({ + fetch: (async () => { + throw new Error("connection refused"); + }) as typeof fetch, + }); + + const result = await client.listSessions(); + + expect(result.ok).toBe(false); + expect(!result.ok && result.error.kind).toBe("network-error"); + expect(!result.ok && result.error.message).toContain("connection refused"); + }); + + test("rejects malformed success payloads", async () => { + const fixture = createFixtureFetch({ + "/s/plan-session-1/api/session": { ok: true, session: { id: "plan-session-1" } }, + }); + const client = createDaemonApiClient({ fetch: fixture.fetch }); + + const result = await client.getSessionBootstrap("plan-session-1"); + + expect(result.ok).toBe(false); + expect(!result.ok && result.error.kind).toBe("invalid-payload"); + }); +}); diff --git a/apps/debug-frontend/src/daemon/api/client.ts b/apps/debug-frontend/src/daemon/api/client.ts new file mode 100644 index 000000000..f567cd997 --- /dev/null +++ b/apps/debug-frontend/src/daemon/api/client.ts @@ -0,0 +1,348 @@ +import { + PLANNOTATOR_DAEMON_PROTOCOL, + type DaemonErrorResponse, +} from "@plannotator/shared/daemon-protocol"; +import type { + ShellDaemonStatus, + ShellDeleteSessionResponse, + ShellSessionBootstrap, + ShellSessionListResponse, + ShellSessionResponse, + ShellSessionSummary, +} from "../contracts"; +import { encodeSessionId } from "../../sessions/session-id"; +import type { DaemonApiError, DaemonApiResult } from "./errors"; + +type FetchLike = typeof fetch; + +export interface DaemonApiClientOptions { + baseUrl?: string; + fetch?: FetchLike; +} + +export interface DaemonApiClient { + getStatus(): Promise>; + listSessions(options?: { clean?: boolean }): Promise>; + getSession(sessionId: string): Promise>; + getSessionBootstrap(sessionId: string): Promise>; + cancelSession(sessionId: string, reason?: string): Promise>; + deleteSession(sessionId: string): Promise>; + getEventsUrl(): string; + getSessionApiUrl(sessionId: string, path: string): string; + probeSessionApi( + sessionId: string, + path: string, + init?: RequestInit, + ): Promise>; + runSessionAction( + session: ShellSessionSummary, + action: ShellSessionAction, + ): Promise>; +} + +type ResponseGuard = (value: unknown) => value is T; + +export type ShellSessionAction = + | "plan-approve" + | "plan-deny" + | "review-approve" + | "review-feedback" + | "review-exit" + | "annotate-approve" + | "annotate-feedback" + | "annotate-exit" + | "archive-done" + | "goal-setup-submit" + | "goal-setup-exit"; + +function joinUrl(baseUrl: string | undefined, path: string): string { + if (!baseUrl) return path; + const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + return new URL(path, normalizedBase).toString(); +} + +function normalizeSessionApiPath(path: string): string { + const prefixed = path.startsWith("/") ? path : `/${path}`; + if (prefixed === "/api") return ""; + if (prefixed.startsWith("/api/")) return prefixed.slice(4); + return prefixed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function hasOkTrue(value: unknown): value is Record & { ok: true } { + return isRecord(value) && value.ok === true; +} + +function isDaemonErrorResponse(value: unknown): value is DaemonErrorResponse { + return ( + isRecord(value) && + value.ok === false && + value.protocol === PLANNOTATOR_DAEMON_PROTOCOL && + isRecord(value.error) && + typeof value.error.code === "string" && + typeof value.error.message === "string" + ); +} + +function isDaemonStatus(value: unknown): value is ShellDaemonStatus { + return ( + hasOkTrue(value) && + value.protocol === PLANNOTATOR_DAEMON_PROTOCOL && + typeof value.pid === "number" && + isRecord(value.endpoint) && + typeof value.endpoint.baseUrl === "string" && + typeof value.startedAt === "string" && + typeof value.activeSessionCount === "number" && + typeof value.sessionCount === "number" + ); +} + +function isUnknownPayload(_value: unknown): _value is unknown { + return true; +} + +function isSessionSummary(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.mode === "string" && + typeof value.status === "string" && + typeof value.url === "string" && + typeof value.project === "string" && + typeof value.label === "string" && + typeof value.createdAt === "string" && + typeof value.updatedAt === "string" + ); +} + +function isSessionList(value: unknown): value is ShellSessionListResponse { + return ( + hasOkTrue(value) && Array.isArray(value.sessions) && value.sessions.every(isSessionSummary) + ); +} + +function isSessionResponse(value: unknown): value is ShellSessionResponse { + return hasOkTrue(value) && isSessionSummary((value as { session?: unknown }).session); +} + +function isDeleteSessionResponse(value: unknown): value is ShellDeleteSessionResponse { + return hasOkTrue(value); +} + +function isSessionBootstrap(value: unknown): value is ShellSessionBootstrap { + return ( + isSessionResponse(value) && + typeof (value as { apiBase?: unknown }).apiBase === "string" && + isRecord((value as { capabilities?: unknown }).capabilities) && + Array.isArray((value as { supportedSessionViews?: unknown }).supportedSessionViews) + ); +} + +function httpError(status: number, message: string): DaemonApiError { + return { kind: "http-error", status, message }; +} + +async function requestJson( + fetchImpl: FetchLike, + url: string, + guard: ResponseGuard, + init?: RequestInit, +): Promise> { + let response: Response; + try { + response = await fetchImpl(url, init); + } catch (cause) { + return { + ok: false, + error: { + kind: "network-error", + message: cause instanceof Error ? cause.message : "Network request failed.", + cause, + }, + }; + } + + const body = await response.text(); + let payload: unknown; + try { + payload = body ? JSON.parse(body) : null; + } catch { + return { + ok: false, + error: { + kind: "invalid-json", + status: response.status, + body, + message: "Daemon returned a non-JSON response.", + }, + }; + } + + if (isDaemonErrorResponse(payload)) { + return { + ok: false, + error: { + kind: "daemon-error", + status: response.status, + code: payload.error.code, + message: payload.error.message, + }, + }; + } + + if (!response.ok) { + return { ok: false, error: httpError(response.status, response.statusText || "HTTP error.") }; + } + + if (!guard(payload)) { + return { + ok: false, + error: { + kind: "invalid-payload", + message: "Daemon response did not match the frontend contract.", + value: payload, + }, + }; + } + + return { ok: true, data: payload }; +} + +function jsonPost(body: unknown): RequestInit { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +function requestForAction(action: ShellSessionAction): { path: string; init: RequestInit } { + switch (action) { + case "plan-approve": + return { path: "/api/approve", init: jsonPost({ planSave: { enabled: false } }) }; + case "plan-deny": + return { + path: "/api/deny", + init: jsonPost({ + feedback: "Plan denied by frontend debug action.", + planSave: { enabled: false }, + }), + }; + case "review-approve": + return { + path: "/api/feedback", + init: jsonPost({ approved: true, feedback: "LGTM", annotations: [] }), + }; + case "review-feedback": + return { + path: "/api/feedback", + init: jsonPost({ + approved: false, + feedback: "Review feedback from frontend debug action.", + annotations: [], + }), + }; + case "review-exit": + return { path: "/api/exit", init: jsonPost({}) }; + case "annotate-approve": + return { path: "/api/approve", init: jsonPost({}) }; + case "annotate-feedback": + return { + path: "/api/feedback", + init: jsonPost({ + feedback: "Annotation feedback from frontend debug action.", + annotations: [], + }), + }; + case "annotate-exit": + return { path: "/api/exit", init: jsonPost({}) }; + case "archive-done": + return { path: "/api/done", init: jsonPost({}) }; + case "goal-setup-submit": + return { path: "/api/goal-setup/submit", init: jsonPost({ answers: [], facts: [] }) }; + case "goal-setup-exit": + return { path: "/api/exit", init: jsonPost({}) }; + } +} + +export function createDaemonApiClient(options: DaemonApiClientOptions = {}): DaemonApiClient { + const fetchImpl = options.fetch ?? fetch; + const getSessionApiUrl = (sessionId: string, path: string) => + joinUrl( + options.baseUrl, + `/s/${encodeSessionId(sessionId)}/api${normalizeSessionApiPath(path)}`, + ); + const probeSessionApi = (sessionId: string, path: string, init?: RequestInit) => + requestJson(fetchImpl, getSessionApiUrl(sessionId, path), isUnknownPayload, init); + + return { + getStatus() { + return requestJson(fetchImpl, joinUrl(options.baseUrl, "/daemon/status"), isDaemonStatus); + }, + + listSessions(listOptions = {}) { + const path = listOptions.clean ? "/daemon/sessions?clean=1" : "/daemon/sessions"; + return requestJson(fetchImpl, joinUrl(options.baseUrl, path), isSessionList); + }, + + getSession(sessionId) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/sessions/${encodeSessionId(sessionId)}`), + isSessionResponse, + ); + }, + + getSessionBootstrap(sessionId) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/s/${encodeSessionId(sessionId)}/api/session`), + isSessionBootstrap, + ); + }, + + cancelSession(sessionId, reason) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/sessions/${encodeSessionId(sessionId)}/cancel`), + isSessionResponse, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason }), + }, + ); + }, + + deleteSession(sessionId) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/sessions/${encodeSessionId(sessionId)}`), + isDeleteSessionResponse, + { method: "DELETE" }, + ); + }, + + getEventsUrl() { + return joinUrl(options.baseUrl, "/daemon/events"); + }, + + getSessionApiUrl(sessionId, path) { + return getSessionApiUrl(sessionId, path); + }, + + probeSessionApi(sessionId, path, init) { + return probeSessionApi(sessionId, path, init); + }, + + runSessionAction(session, action) { + const request = requestForAction(action); + return probeSessionApi(session.id, request.path, request.init); + }, + }; +} + +export const daemonApiClient = createDaemonApiClient(); diff --git a/apps/debug-frontend/src/daemon/api/errors.ts b/apps/debug-frontend/src/daemon/api/errors.ts new file mode 100644 index 000000000..6d805a11c --- /dev/null +++ b/apps/debug-frontend/src/daemon/api/errors.ts @@ -0,0 +1,44 @@ +import type { DaemonErrorCode } from "@plannotator/shared/daemon-protocol"; + +export type DaemonApiError = + | { + kind: "network-error"; + message: string; + cause?: unknown; + } + | { + kind: "invalid-json"; + status: number; + body: string; + message: string; + } + | { + kind: "daemon-error"; + status: number; + code: DaemonErrorCode; + message: string; + } + | { + kind: "http-error"; + status: number; + message: string; + } + | { + kind: "invalid-payload"; + message: string; + value: unknown; + }; + +export type DaemonApiResult = + | { + ok: true; + data: T; + } + | { + ok: false; + error: DaemonApiError; + }; + +export function errorMessage(error: DaemonApiError): string { + return error.message; +} diff --git a/apps/debug-frontend/src/daemon/contracts.ts b/apps/debug-frontend/src/daemon/contracts.ts new file mode 100644 index 000000000..57b1d8b76 --- /dev/null +++ b/apps/debug-frontend/src/daemon/contracts.ts @@ -0,0 +1,55 @@ +import type { + DaemonEndpoint, + DaemonErrorResponse, + DaemonEvent, + DaemonSessionBootstrapResponse, + DaemonSessionStatus, + DaemonSessionSummary, + DaemonSessionView, + DaemonStatus, +} from "@plannotator/shared/daemon-protocol"; + +export type ShellSessionView = DaemonSessionView; +export type ShellSessionMode = ShellSessionView | (string & {}); + +export interface ShellSessionSummary extends Omit { + mode: ShellSessionMode; +} + +export interface ShellDaemonStatus extends Omit { + endpoint: DaemonEndpoint; +} + +export interface ShellSessionBootstrap extends Omit { + session: ShellSessionSummary; +} + +export interface ShellSessionListResponse { + ok: true; + sessions: ShellSessionSummary[]; +} + +export interface ShellSessionResponse { + ok: true; + session: ShellSessionSummary; +} + +export interface ShellDeleteSessionResponse { + ok: true; +} + +export type ShellDaemonResponse = T | DaemonErrorResponse; + +export type ShellSessionLifecycleStatus = DaemonSessionStatus; +export type ShellDaemonEvent = + | (Omit, "sessions"> & { + sessions: ShellSessionSummary[]; + }) + | Extract + | Extract + | (Omit< + Extract, + "session" + > & { + session: ShellSessionSummary; + }); diff --git a/apps/debug-frontend/src/daemon/events/event-store.test.ts b/apps/debug-frontend/src/daemon/events/event-store.test.ts new file mode 100644 index 000000000..a5059f107 --- /dev/null +++ b/apps/debug-frontend/src/daemon/events/event-store.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { daemonStatusFixture, sessionSummary } from "../../testing/fixtures/daemon"; +import { applyDaemonEvent, createInitialDaemonDebugState } from "./event-store"; + +describe("daemon debug event store reducer", () => { + test("applies snapshot events as the canonical daemon state", () => { + const state = createInitialDaemonDebugState(); + const session = sessionSummary("plan", 1); + + applyDaemonEvent(state, { + type: "snapshot", + at: "2026-05-17T12:00:01.000Z", + status: daemonStatusFixture, + sessions: [session], + }); + + expect(state.status).toBe(daemonStatusFixture); + expect(state.sessions).toEqual([session]); + expect(state.events).toHaveLength(1); + }); + + test("tracks lifecycle updates and removes terminal sessions", () => { + const state = createInitialDaemonDebugState(); + const session = sessionSummary("review", 2); + + applyDaemonEvent(state, { + type: "session-created", + at: "2026-05-17T12:00:01.000Z", + session, + }); + expect(state.sessions).toEqual([session]); + + applyDaemonEvent(state, { + type: "session-updated", + at: "2026-05-17T12:00:02.000Z", + session: { ...session, status: "completed" }, + }); + + expect(state.sessions).toEqual([]); + expect(state.events.map((event) => event.type)).toEqual(["session-updated", "session-created"]); + }); + + test("records daemon errors without dropping prior events", () => { + const state = createInitialDaemonDebugState(); + + applyDaemonEvent(state, { + type: "daemon-error", + at: "2026-05-17T12:00:01.000Z", + code: "internal-error", + message: "session setup failed", + }); + + expect(state.lastError).toBe("session setup failed"); + expect(state.events[0].type).toBe("daemon-error"); + }); +}); diff --git a/apps/debug-frontend/src/daemon/events/event-store.ts b/apps/debug-frontend/src/daemon/events/event-store.ts new file mode 100644 index 000000000..cdb3bf0b4 --- /dev/null +++ b/apps/debug-frontend/src/daemon/events/event-store.ts @@ -0,0 +1,105 @@ +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import type { ShellDaemonEvent, ShellDaemonStatus, ShellSessionSummary } from "../contracts"; + +const MAX_EVENTS = 100; +const TERMINAL_STATUSES = new Set(["completed", "cancelled", "expired", "failed"]); + +export type DaemonEventConnectionState = "idle" | "connecting" | "open" | "polling" | "error"; + +export interface DaemonDebugStateSnapshot { + connectionState: DaemonEventConnectionState; + events: ShellDaemonEvent[]; + sessions: ShellSessionSummary[]; + status?: ShellDaemonStatus; + lastError?: string; + lastUpdatedAt?: string; +} + +export interface DaemonDebugState extends DaemonDebugStateSnapshot { + setConnectionState(state: DaemonEventConnectionState): void; + setError(message: string): void; + replaceSessions(sessions: ShellSessionSummary[]): void; + applyEvent(event: ShellDaemonEvent): void; + reset(): void; +} + +export function createInitialDaemonDebugState(): DaemonDebugStateSnapshot { + return { + connectionState: "idle", + events: [], + sessions: [], + }; +} + +export function applyDaemonEvent(state: DaemonDebugStateSnapshot, event: ShellDaemonEvent): void { + state.events = [event, ...state.events].slice(0, MAX_EVENTS); + state.lastUpdatedAt = event.at; + + if (event.type === "snapshot") { + state.status = event.status; + state.sessions = event.sessions; + return; + } + + if (event.type === "daemon-status") { + state.status = event.status; + return; + } + + if (event.type === "daemon-error") { + state.lastError = event.message; + return; + } + + if (event.type === "debug-log") { + return; + } + + const existingIndex = state.sessions.findIndex((session) => session.id === event.session.id); + if (event.type === "session-removed" || TERMINAL_STATUSES.has(event.session.status)) { + if (existingIndex >= 0) state.sessions.splice(existingIndex, 1); + return; + } + + if (existingIndex >= 0) { + state.sessions[existingIndex] = event.session; + } else { + state.sessions.unshift(event.session); + } +} + +export const useDaemonDebugStore = create()( + immer((set) => ({ + ...createInitialDaemonDebugState(), + + setConnectionState(connectionState) { + set((state) => { + state.connectionState = connectionState; + }); + }, + + setError(message) { + set((state) => { + state.connectionState = "error"; + state.lastError = message; + }); + }, + + replaceSessions(sessions) { + set((state) => { + state.sessions = sessions; + }); + }, + + applyEvent(event) { + set((state) => { + applyDaemonEvent(state, event); + }); + }, + + reset() { + set(createInitialDaemonDebugState()); + }, + })), +); diff --git a/apps/debug-frontend/src/daemon/events/event-stream.test.ts b/apps/debug-frontend/src/daemon/events/event-stream.test.ts new file mode 100644 index 000000000..06e531e1b --- /dev/null +++ b/apps/debug-frontend/src/daemon/events/event-stream.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, vi } from "vitest"; +import { daemonStatusFixture, sessionListFixture } from "../../testing/fixtures/daemon"; +import { connectDaemonEvents, parseDaemonEventPayload, type EventSourceLike } from "./event-stream"; + +class FakeEventSource implements EventSourceLike { + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + closed = false; + listeners = new Map) => void>(); + + addEventListener(type: string, listener: (event: MessageEvent) => void): void { + this.listeners.set(type, listener); + } + + emit(type: string, payload: unknown): void { + this.listeners.get(type)?.({ data: JSON.stringify(payload) } as MessageEvent); + } + + close(): void { + this.closed = true; + } +} + +describe("daemon event stream client", () => { + test("parses daemon event payloads defensively", () => { + const event = parseDaemonEventPayload( + JSON.stringify({ + type: "daemon-status", + at: "2026-05-17T12:00:00.000Z", + status: daemonStatusFixture, + }), + ); + + expect(event?.type).toBe("daemon-status"); + expect( + parseDaemonEventPayload( + JSON.stringify({ + type: "debug-log", + at: "2026-05-17T12:00:00.000Z", + source: "agent-simulator", + message: "queued claude-plan-hook", + }), + )?.type, + ).toBe("debug-log"); + expect(parseDaemonEventPayload("not json")).toBeNull(); + expect(parseDaemonEventPayload(JSON.stringify({ type: "unknown", at: "now" }))).toBeNull(); + }); + + test("streams typed EventSource events into the frontend state callback", () => { + const events: string[] = []; + const states: string[] = []; + const source = new FakeEventSource(); + + const controller = connectDaemonEvents({ + client: { + getEventsUrl: () => "/daemon/events", + getStatus: async () => ({ ok: true, data: daemonStatusFixture }), + listSessions: async () => ({ ok: true, data: sessionListFixture }), + }, + eventSourceFactory: () => source, + onEvent: (event) => events.push(event.type), + onState: (state) => states.push(state), + onError: (message) => events.push(message), + }); + + source.onopen?.(new Event("open")); + source.emit("snapshot", { + type: "snapshot", + at: "2026-05-17T12:00:00.000Z", + status: daemonStatusFixture, + sessions: sessionListFixture.sessions, + }); + controller.stop(); + + expect(states).toEqual(["connecting", "open"]); + expect(events).toEqual(["snapshot"]); + expect(source.closed).toBe(true); + }); + + test("falls back to polling when EventSource is unavailable", async () => { + const events: string[] = []; + const states: string[] = []; + const intervalHandles: unknown[] = []; + const setIntervalFn = vi.fn((callback: () => void, _intervalMs: number) => { + intervalHandles.push(callback); + return { unref: vi.fn() } as unknown as ReturnType & { + unref?: () => void; + }; + }); + const clearIntervalFn = vi.fn(); + + const controller = connectDaemonEvents({ + client: { + getEventsUrl: () => "/daemon/events", + getStatus: async () => ({ ok: true, data: daemonStatusFixture }), + listSessions: async () => ({ ok: true, data: sessionListFixture }), + }, + eventSourceFactory: undefined, + setIntervalFn, + clearIntervalFn, + onEvent: (event) => events.push(event.type), + onState: (state) => states.push(state), + onError: (message) => events.push(message), + }); + + await Promise.resolve(); + await Promise.resolve(); + controller.stop(); + + expect(states).toEqual(["polling", "polling"]); + expect(events).toContain("snapshot"); + expect(setIntervalFn).toHaveBeenCalledTimes(1); + expect(clearIntervalFn).toHaveBeenCalledTimes(1); + expect(intervalHandles).toHaveLength(1); + }); +}); diff --git a/apps/debug-frontend/src/daemon/events/event-stream.ts b/apps/debug-frontend/src/daemon/events/event-stream.ts new file mode 100644 index 000000000..28624065c --- /dev/null +++ b/apps/debug-frontend/src/daemon/events/event-stream.ts @@ -0,0 +1,161 @@ +import type { DaemonApiClient } from "../api/client"; +import type { DaemonApiResult } from "../api/errors"; +import type { ShellDaemonEvent, ShellDaemonStatus, ShellSessionListResponse } from "../contracts"; + +export interface EventSourceLike { + onopen: ((event: Event) => void) | null; + onerror: ((event: Event) => void) | null; + addEventListener(type: string, listener: (event: MessageEvent) => void): void; + close(): void; +} + +type IntervalHandle = ReturnType & { unref?: () => void }; +type SetIntervalLike = (callback: () => void, intervalMs: number) => IntervalHandle; +type ClearIntervalLike = (handle: IntervalHandle) => void; + +export interface DaemonEventStreamOptions { + client: Pick; + onEvent(event: ShellDaemonEvent): void; + onState(state: "connecting" | "open" | "polling" | "error"): void; + onError(message: string): void; + eventSourceFactory?: (url: string) => EventSourceLike; + pollIntervalMs?: number; + setIntervalFn?: SetIntervalLike; + clearIntervalFn?: ClearIntervalLike; +} + +export interface DaemonEventStreamController { + stop(): void; +} + +const DAEMON_EVENT_TYPES = [ + "snapshot", + "daemon-status", + "session-created", + "session-updated", + "session-removed", + "daemon-error", + "debug-log", +] as const; + +export function parseDaemonEventPayload(payload: string): ShellDaemonEvent | null { + try { + const value = JSON.parse(payload) as Partial | null; + if (!value || typeof value !== "object" || typeof value.type !== "string") return null; + if (!DAEMON_EVENT_TYPES.includes(value.type as (typeof DAEMON_EVENT_TYPES)[number])) + return null; + if (typeof value.at !== "string") return null; + return value as ShellDaemonEvent; + } catch { + return null; + } +} + +export function connectDaemonEvents( + options: DaemonEventStreamOptions, +): DaemonEventStreamController { + const pollIntervalMs = options.pollIntervalMs ?? 2_000; + const setIntervalFn: SetIntervalLike = + options.setIntervalFn ?? + ((callback, intervalMs) => setInterval(callback, intervalMs) as IntervalHandle); + const clearIntervalFn: ClearIntervalLike = + options.clearIntervalFn ?? ((handle) => clearInterval(handle)); + let stopped = false; + let pollingTimer: IntervalHandle | undefined; + let eventSource: EventSourceLike | undefined; + + const stopPolling = () => { + if (!pollingTimer) return; + clearIntervalFn(pollingTimer); + pollingTimer = undefined; + }; + + const poll = async () => { + const [statusResult, sessionsResult] = await Promise.all([ + options.client.getStatus(), + options.client.listSessions({ clean: true }), + ]); + if (stopped) return; + emitPollingResult(statusResult, sessionsResult, { + onEvent: options.onEvent, + onError: options.onError, + onState: options.onState, + }); + }; + + const startPolling = () => { + if (stopped || pollingTimer) return; + options.onState("polling"); + void poll(); + pollingTimer = setIntervalFn(() => void poll(), pollIntervalMs); + pollingTimer.unref?.(); + }; + + const factory = options.eventSourceFactory ?? defaultEventSourceFactory(); + if (!factory) { + startPolling(); + return { stop }; + } + + options.onState("connecting"); + eventSource = factory(options.client.getEventsUrl()); + eventSource.onopen = () => { + if (!stopped) options.onState("open"); + }; + eventSource.onerror = () => { + if (stopped) return; + options.onError("Daemon event stream disconnected; falling back to polling."); + options.onState("error"); + eventSource?.close(); + eventSource = undefined; + startPolling(); + }; + + for (const eventType of DAEMON_EVENT_TYPES) { + eventSource.addEventListener(eventType, (event) => { + const parsed = parseDaemonEventPayload(event.data); + if (parsed && !stopped) options.onEvent(parsed); + }); + } + + return { stop }; + + function stop() { + stopped = true; + stopPolling(); + eventSource?.close(); + eventSource = undefined; + } +} + +function defaultEventSourceFactory(): ((url: string) => EventSourceLike) | undefined { + if (typeof EventSource === "undefined") return undefined; + return (url) => new EventSource(url); +} + +function emitPollingResult( + statusResult: DaemonApiResult, + sessionsResult: DaemonApiResult, + options: Pick, +): void { + const at = new Date().toISOString(); + if (!statusResult.ok) { + options.onError(statusResult.error.message); + return; + } + + options.onState("polling"); + + if (!sessionsResult.ok) { + options.onError(sessionsResult.error.message); + options.onEvent({ type: "daemon-status", at, status: statusResult.data }); + return; + } + + options.onEvent({ + type: "snapshot", + at, + status: statusResult.data, + sessions: sessionsResult.data.sessions, + }); +} diff --git a/apps/debug-frontend/src/daemon/events/use-daemon-events.ts b/apps/debug-frontend/src/daemon/events/use-daemon-events.ts new file mode 100644 index 000000000..f08705133 --- /dev/null +++ b/apps/debug-frontend/src/daemon/events/use-daemon-events.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { daemonApiClient, type DaemonApiClient } from "../api/client"; +import { connectDaemonEvents } from "./event-stream"; +import { useDaemonDebugStore } from "./event-store"; + +export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabled = true): void { + const applyEvent = useDaemonDebugStore((state) => state.applyEvent); + const setConnectionState = useDaemonDebugStore((state) => state.setConnectionState); + const setError = useDaemonDebugStore((state) => state.setError); + + useEffect(() => { + if (!enabled) return undefined; + const controller = connectDaemonEvents({ + client, + onEvent: applyEvent, + onState: setConnectionState, + onError: setError, + }); + + return () => controller.stop(); + }, [applyEvent, client, enabled, setConnectionState, setError]); +} diff --git a/apps/debug-frontend/src/debug/EventLog.tsx b/apps/debug-frontend/src/debug/EventLog.tsx new file mode 100644 index 000000000..7899dbd3a --- /dev/null +++ b/apps/debug-frontend/src/debug/EventLog.tsx @@ -0,0 +1,81 @@ +import type { ShellDaemonEvent } from "../daemon/contracts"; +import { useDaemonDebugStore } from "../daemon/events/event-store"; + +export function EventLog() { + const events = useDaemonDebugStore((state) => state.events); + const connectionState = useDaemonDebugStore((state) => state.connectionState); + + if (events.length === 0 && connectionState === "idle") return null; + + return ( +
+
+

Event log

+ {connectionState} +
+ {events.length === 0 ? ( +

Waiting for events...

+ ) : ( +
    + {events.slice(0, 30).map((event, index) => ( + + ))} +
+ )} +
+ ); +} + +function EventRow({ event }: { event: ShellDaemonEvent }) { + const time = formatEventTime(event.at); + + if (event.type === "debug-log") { + return ( +
  • + + {event.source} + {event.message} +
  • + ); + } + + if (event.type === "session-created" || event.type === "session-updated") { + return ( +
  • + + {event.type} + {event.session.id} +
  • + ); + } + + if (event.type === "daemon-status" || event.type === "snapshot") { + return ( +
  • + + {event.type} + + {event.status.activeSessionCount} active, pid {event.status.pid} + +
  • + ); + } + + return ( +
  • + + {event.type} +
  • + ); +} + +function formatEventTime(isoString: string): string { + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) return isoString; + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 2, + }); +} diff --git a/apps/debug-frontend/src/debug/SessionDebugPanel.browser.tsx b/apps/debug-frontend/src/debug/SessionDebugPanel.browser.tsx new file mode 100644 index 000000000..61c976f07 --- /dev/null +++ b/apps/debug-frontend/src/debug/SessionDebugPanel.browser.tsx @@ -0,0 +1,81 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act } from "react"; +import { afterEach, describe, expect, test } from "vitest"; +import type { DaemonApiClient } from "../daemon/api/client"; +import { cleanupBrowser, renderBrowser } from "../testing/browser/render"; +import { sessionBootstraps } from "../testing/fixtures/daemon"; +import { SessionDebugPanel } from "./SessionDebugPanel"; + +let cleanup: (() => Promise) | undefined; + +afterEach(async () => { + await cleanup?.(); + cleanup = undefined; +}); + +function wrapWithRouter(ui: React.ReactNode) { + const rootRoute = createRootRoute({ component: () => ui }); + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ["/s/test-session"] }), + }); + return ; +} + +describe("SessionDebugPanel browser rendering", () => { + test("runs API probes and mode-specific action buttons", async () => { + const calls: string[] = []; + const client: DaemonApiClient = { + getStatus: async () => { + throw new Error("not used"); + }, + listSessions: async () => { + throw new Error("not used"); + }, + getSession: async () => { + throw new Error("not used"); + }, + getSessionBootstrap: async () => { + throw new Error("not used"); + }, + cancelSession: async () => { + throw new Error("not used"); + }, + deleteSession: async () => { + throw new Error("not used"); + }, + getEventsUrl: () => "/daemon/events", + getSessionApiUrl: (sessionId, path) => `/s/${sessionId}${path}`, + probeSessionApi: async (_sessionId, path) => { + calls.push(`probe:${path}`); + return { ok: true, data: { ok: true, path } }; + }, + runSessionAction: async (_session, action) => { + calls.push(`action:${action}`); + return { ok: true, data: { ok: true, action } }; + }, + }; + + const rendered = await renderBrowser( + wrapWithRouter(), + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + const buttons = Array.from(rendered.container.querySelectorAll("button")); + await act(async () => { + buttons.find((button) => button.textContent?.includes("/api/diff"))?.click(); + }); + await act(async () => { + buttons.find((button) => button.textContent === "LGTM")?.click(); + }); + + expect(calls).toEqual(["probe:/api/diff", "action:review-approve"]); + }); +}); diff --git a/apps/debug-frontend/src/debug/SessionDebugPanel.tsx b/apps/debug-frontend/src/debug/SessionDebugPanel.tsx new file mode 100644 index 000000000..188feb018 --- /dev/null +++ b/apps/debug-frontend/src/debug/SessionDebugPanel.tsx @@ -0,0 +1,121 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { + daemonApiClient, + type DaemonApiClient, + type ShellSessionAction, +} from "../daemon/api/client"; +import type { ShellSessionBootstrap, ShellSessionSummary } from "../daemon/contracts"; + +interface SessionDebugPanelProps { + bootstrap: ShellSessionBootstrap; + client?: DaemonApiClient; +} + +type ActionRole = "approve" | "deny" | "secondary"; + +interface SessionActionDef { + action: ShellSessionAction; + label: string; + role: ActionRole; +} + +const SESSION_ACTIONS: Partial> = { + plan: [ + { action: "plan-approve", label: "Approve", role: "approve" }, + { action: "plan-deny", label: "Deny", role: "deny" }, + ], + review: [ + { action: "review-approve", label: "LGTM", role: "approve" }, + { action: "review-feedback", label: "Feedback", role: "secondary" }, + { action: "review-exit", label: "Exit", role: "secondary" }, + ], + annotate: [ + { action: "annotate-approve", label: "Approve", role: "approve" }, + { action: "annotate-feedback", label: "Feedback", role: "secondary" }, + { action: "annotate-exit", label: "Exit", role: "secondary" }, + ], + archive: [{ action: "archive-done", label: "Close", role: "secondary" }], + "setup-goal": [ + { action: "goal-setup-submit", label: "Submit", role: "approve" }, + { action: "goal-setup-exit", label: "Exit", role: "deny" }, + ], +}; + +export function SessionDebugPanel({ bootstrap, client = daemonApiClient }: SessionDebugPanelProps) { + const navigate = useNavigate(); + const [busyLabel, setBusyLabel] = useState(null); + const [lastResult, setLastResult] = useState<{ label: string; payload: unknown } | null>(null); + const probePath = useMemo(() => probePathForSession(bootstrap.session), [bootstrap.session]); + const actions = SESSION_ACTIONS[bootstrap.session.mode] ?? []; + + const runProbe = async () => { + setBusyLabel("Probe"); + try { + const response = await client.probeSessionApi(bootstrap.session.id, probePath); + setLastResult({ label: `GET ${probePath}`, payload: response }); + } catch (err) { + setLastResult({ label: `GET ${probePath}`, payload: formatError(err) }); + } finally { + setBusyLabel(null); + } + }; + + const runAction = async (action: ShellSessionAction, label: string) => { + setBusyLabel(label); + try { + await client.runSessionAction(bootstrap.session, action); + void navigate({ to: "/" }); + } catch (err) { + setLastResult({ label, payload: formatError(err) }); + setBusyLabel(null); + } + }; + + return ( +
    +
    + {actions.map(({ action, label, role }) => ( + + ))} +
    +
    + +
    + + {lastResult ? ( +
    + {lastResult.label} +
    {JSON.stringify(lastResult.payload, null, 2)}
    +
    + ) : null} + +
    + Session bootstrap +
    {JSON.stringify(bootstrap, null, 2)}
    +
    +
    + ); +} + +function probePathForSession(session: ShellSessionSummary): string { + if (session.mode === "review") return "/api/diff"; + return "/api/plan"; +} + +function formatError(err: unknown): { ok: false; message: string } { + return { + ok: false, + message: err instanceof Error ? err.message : "Unexpected error.", + }; +} diff --git a/apps/debug-frontend/src/main.tsx b/apps/debug-frontend/src/main.tsx new file mode 100644 index 000000000..6b88ea600 --- /dev/null +++ b/apps/debug-frontend/src/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { RouterProvider } from "@tanstack/react-router"; +import { ThemeProvider } from "@plannotator/ui/components/ThemeProvider"; +import { createAppRouter } from "./app/router"; +import "./styles.css"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Plannotator frontend root element was not found."); +} + +const router = createAppRouter(); + +createRoot(rootElement).render( + + + + + , +); diff --git a/apps/debug-frontend/src/plan/PlanSessionView.tsx b/apps/debug-frontend/src/plan/PlanSessionView.tsx new file mode 100644 index 000000000..65de5e4cd --- /dev/null +++ b/apps/debug-frontend/src/plan/PlanSessionView.tsx @@ -0,0 +1,21 @@ +import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups"; +import { ApiGroupList } from "../shared/ui/ApiGroupList"; +import { SessionFacts } from "../shared/ui/SessionFacts"; +import type { SessionViewComponentProps } from "../sessions/session-view-registry"; + +export function PlanSessionView({ bootstrap }: SessionViewComponentProps) { + return ( +
    +
    +

    Plan review

    +

    {bootstrap.session.label}

    +

    + Skeleton for plan approval, denial, annotations, version history, archive sidebar, linked + docs, image attachments, and note export. +

    +
    + + +
    + ); +} diff --git a/apps/debug-frontend/src/review/ReviewSessionView.tsx b/apps/debug-frontend/src/review/ReviewSessionView.tsx new file mode 100644 index 000000000..ddfcef144 --- /dev/null +++ b/apps/debug-frontend/src/review/ReviewSessionView.tsx @@ -0,0 +1,21 @@ +import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups"; +import { ApiGroupList } from "../shared/ui/ApiGroupList"; +import { SessionFacts } from "../shared/ui/SessionFacts"; +import type { SessionViewComponentProps } from "../sessions/session-view-registry"; + +export function ReviewSessionView({ bootstrap }: SessionViewComponentProps) { + return ( +
    +
    +

    Code review

    +

    {bootstrap.session.label}

    +

    + Skeleton for diff browsing, PR switching, AI chat, agent jobs, code navigation, staging, + platform actions, drafts, and review feedback. +

    +
    + + +
    + ); +} diff --git a/apps/debug-frontend/src/routeTree.gen.ts b/apps/debug-frontend/src/routeTree.gen.ts new file mode 100644 index 000000000..8585fff6a --- /dev/null +++ b/apps/debug-frontend/src/routeTree.gen.ts @@ -0,0 +1,77 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from "./routes/__root" +import { Route as IndexRouteImport } from "./routes/index" +import { Route as SSessionIdRouteImport } from "./routes/s.$sessionId" + +const IndexRoute = IndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => rootRouteImport, +} as any) +const SSessionIdRoute = SSessionIdRouteImport.update({ + id: "/s/$sessionId", + path: "/s/$sessionId", + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + "/": typeof IndexRoute + "/s/$sessionId": typeof SSessionIdRoute +} +export interface FileRoutesByTo { + "/": typeof IndexRoute + "/s/$sessionId": typeof SSessionIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + "/": typeof IndexRoute + "/s/$sessionId": typeof SSessionIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: "/" | "/s/$sessionId" + fileRoutesByTo: FileRoutesByTo + to: "/" | "/s/$sessionId" + id: "__root__" | "/" | "/s/$sessionId" + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + SSessionIdRoute: typeof SSessionIdRoute +} + +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/" + path: "/" + fullPath: "/" + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + "/s/$sessionId": { + id: "/s/$sessionId" + path: "/s/$sessionId" + fullPath: "/s/$sessionId" + preLoaderRoute: typeof SSessionIdRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + SSessionIdRoute: SSessionIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/debug-frontend/src/routes/__root.tsx b/apps/debug-frontend/src/routes/__root.tsx new file mode 100644 index 000000000..b44b09155 --- /dev/null +++ b/apps/debug-frontend/src/routes/__root.tsx @@ -0,0 +1,7 @@ +import { createRootRouteWithContext } from "@tanstack/react-router"; +import { ShellLayout } from "../app/layout/ShellLayout"; +import type { AppRouterContext } from "../app/router"; + +export const Route = createRootRouteWithContext()({ + component: ShellLayout, +}); diff --git a/apps/debug-frontend/src/routes/index.tsx b/apps/debug-frontend/src/routes/index.tsx new file mode 100644 index 000000000..111fbf126 --- /dev/null +++ b/apps/debug-frontend/src/routes/index.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SessionDashboard } from "../sessions/SessionDashboard"; + +export const Route = createFileRoute("/")({ + loader: ({ context }) => context.daemonClient.listSessions({ clean: true }), + component: DashboardRoute, +}); + +function DashboardRoute() { + const result = Route.useLoaderData(); + return ; +} diff --git a/apps/debug-frontend/src/routes/s.$sessionId.tsx b/apps/debug-frontend/src/routes/s.$sessionId.tsx new file mode 100644 index 000000000..25709b552 --- /dev/null +++ b/apps/debug-frontend/src/routes/s.$sessionId.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { parseSessionId } from "../sessions/session-id"; +import { SessionRouteView } from "../sessions/SessionRouteView"; + +export const Route = createFileRoute("/s/$sessionId")({ + params: { + parse: ({ sessionId }) => { + const parsed = parseSessionId(sessionId); + return parsed === false ? false : { sessionId: parsed }; + }, + stringify: ({ sessionId }) => ({ sessionId }), + }, + loader: ({ context, params }) => context.daemonClient.getSessionBootstrap(params.sessionId), + component: SessionRoute, +}); + +function SessionRoute() { + const result = Route.useLoaderData(); + return ; +} diff --git a/apps/debug-frontend/src/sessions/SessionDashboard.browser.tsx b/apps/debug-frontend/src/sessions/SessionDashboard.browser.tsx new file mode 100644 index 000000000..13870eb74 --- /dev/null +++ b/apps/debug-frontend/src/sessions/SessionDashboard.browser.tsx @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from "vitest"; +import "../styles.css"; +import { useDaemonDebugStore } from "../daemon/events/event-store"; +import { cleanupBrowser, renderBrowser } from "../testing/browser/render"; +import { emptySessionListFixture, sessionListFixture } from "../testing/fixtures/daemon"; +import { SessionDashboard } from "./SessionDashboard"; + +let cleanup: (() => Promise) | undefined; + +afterEach(async () => { + await cleanup?.(); + cleanup = undefined; + useDaemonDebugStore.getState().reset(); +}); + +describe("SessionDashboard browser rendering", () => { + test("renders the empty state", async () => { + const rendered = await renderBrowser( + , + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + expect(rendered.container.textContent).toContain("No active sessions"); + }); + + test("renders session cards", async () => { + const rendered = await renderBrowser( + , + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + expect(rendered.container.textContent).toContain("Runtime frontend shell plan"); + expect(rendered.container.textContent).toContain("PR #734 daemon runtime review"); + expect(rendered.container.querySelectorAll(".session-card")).toHaveLength(5); + }); + + test("session IDs are selectable text", async () => { + const rendered = await renderBrowser( + , + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + const sessionId = rendered.container.querySelector(".session-card-id"); + expect(sessionId).toBeTruthy(); + expect(getComputedStyle(sessionId as Element).userSelect).not.toBe("none"); + }); + + test("renders backend errors", async () => { + const rendered = await renderBrowser( + , + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + expect(rendered.container.textContent).toContain("Daemon unavailable"); + expect(rendered.container.textContent).toContain("daemon offline"); + }); +}); diff --git a/apps/debug-frontend/src/sessions/SessionDashboard.tsx b/apps/debug-frontend/src/sessions/SessionDashboard.tsx new file mode 100644 index 000000000..23f342ebe --- /dev/null +++ b/apps/debug-frontend/src/sessions/SessionDashboard.tsx @@ -0,0 +1,93 @@ +import { useEffect } from "react"; +import type { DaemonApiResult } from "../daemon/api/errors"; +import { EventLog } from "../debug/EventLog"; +import { useDaemonDebugStore } from "../daemon/events/event-store"; +import { useDaemonEvents } from "../daemon/events/use-daemon-events"; +import { ResultNotice } from "../shared/ui/ResultNotice"; +import type { ShellSessionListResponse, ShellSessionSummary } from "./types"; + +interface SessionDashboardProps { + result: DaemonApiResult; + debugStream?: boolean; +} + +export function SessionDashboard({ result, debugStream = true }: SessionDashboardProps) { + const replaceSessions = useDaemonDebugStore((state) => state.replaceSessions); + const liveSessions = useDaemonDebugStore((state) => state.sessions); + const status = useDaemonDebugStore((state) => state.status); + + useDaemonEvents(undefined, debugStream); + + useEffect(() => { + if (result.ok) { + replaceSessions(result.data.sessions); + } + }, [result, replaceSessions]); + + if (!result.ok && liveSessions.length === 0) { + return ( + <> + + + + ); + } + + const sessions = liveSessions; + + return ( + <> +
    +
    +

    Sessions

    + {status ? ( +

    + pid {status.pid} · {status.endpoint.baseUrl} +

    + ) : null} +
    +
    + + {sessions.length === 0 ? ( +
    +

    No active sessions. Start a plan, review, or annotate flow to see it here.

    +
    + ) : ( + + )} + + + + ); +} + +function SessionList({ sessions }: { sessions: ShellSessionSummary[] }) { + return ( +
      + {sessions.map((session) => ( +
    • +
      + {session.mode} + {session.project} + {formatTime(session.updatedAt)} +
      + {session.label} + {session.id} + + Open session + +
    • + ))} +
    + ); +} + +function formatTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/apps/debug-frontend/src/sessions/SessionRouteView.browser.tsx b/apps/debug-frontend/src/sessions/SessionRouteView.browser.tsx new file mode 100644 index 000000000..5d5824586 --- /dev/null +++ b/apps/debug-frontend/src/sessions/SessionRouteView.browser.tsx @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { cleanupBrowser, renderBrowser } from "../testing/browser/render"; +import { sessionBootstraps } from "../testing/fixtures/daemon"; +import { SessionRouteView } from "./SessionRouteView"; + +let cleanup: (() => Promise) | undefined; + +afterEach(async () => { + await cleanup?.(); + cleanup = undefined; +}); + +describe("SessionRouteView browser rendering", () => { + test.each([ + ["plan", "Plan review"], + ["review", "Code review"], + ["annotate", "Annotate"], + ["archive", "Archive"], + ["setup-goal", "Setup goal"], + ] as const)("renders %s session shell", async (mode, heading) => { + const rendered = await renderBrowser( + , + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + expect(rendered.container.textContent).toContain(heading); + expect(rendered.container.textContent).toContain(sessionBootstraps[mode].apiBase); + }); + + test("renders unsupported sessions deliberately", async () => { + const rendered = await renderBrowser( + , + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + expect(rendered.container.textContent).toContain("Unsupported session"); + expect(rendered.container.textContent).toContain("unknown-mode"); + }); + + test("renders bootstrap failure", async () => { + const rendered = await renderBrowser( + , + ); + cleanup = () => cleanupBrowser(rendered.root, rendered.container); + + expect(rendered.container.textContent).toContain("Session could not be loaded"); + expect(rendered.container.textContent).toContain("Session not found."); + }); +}); diff --git a/apps/debug-frontend/src/sessions/SessionRouteView.tsx b/apps/debug-frontend/src/sessions/SessionRouteView.tsx new file mode 100644 index 000000000..bd7a927da --- /dev/null +++ b/apps/debug-frontend/src/sessions/SessionRouteView.tsx @@ -0,0 +1,34 @@ +import type { DaemonApiResult } from "../daemon/api/errors"; +import { SessionDebugPanel } from "../debug/SessionDebugPanel"; +import { ResultNotice } from "../shared/ui/ResultNotice"; +import type { ShellSessionBootstrap } from "./types"; +import { getSessionViewDefinition } from "./session-view-registry"; +import { UnsupportedSessionView } from "./UnsupportedSessionView"; + +interface SessionRouteViewProps { + result: DaemonApiResult; +} + +export function SessionRouteView({ result }: SessionRouteViewProps) { + if (!result.ok) { + return ; + } + + const definition = getSessionViewDefinition(result.data.session.mode); + if (!definition) { + return ( + <> + + + + ); + } + + const Component = definition.component; + return ( + <> + + + + ); +} diff --git a/apps/debug-frontend/src/sessions/UnsupportedSessionView.tsx b/apps/debug-frontend/src/sessions/UnsupportedSessionView.tsx new file mode 100644 index 000000000..23a4f8e28 --- /dev/null +++ b/apps/debug-frontend/src/sessions/UnsupportedSessionView.tsx @@ -0,0 +1,22 @@ +import { SessionFacts } from "../shared/ui/SessionFacts"; +import type { ShellSessionBootstrap } from "./types"; + +interface UnsupportedSessionViewProps { + bootstrap: ShellSessionBootstrap; +} + +export function UnsupportedSessionView({ bootstrap }: UnsupportedSessionViewProps) { + return ( +
    +
    +

    Unsupported session

    +

    {bootstrap.session.label}

    +

    + This shell received mode {bootstrap.session.mode}. The route and bootstrap + contract are working, but no product view owns this mode yet. +

    +
    + +
    + ); +} diff --git a/apps/debug-frontend/src/sessions/session-api-groups.ts b/apps/debug-frontend/src/sessions/session-api-groups.ts new file mode 100644 index 000000000..7a0087ba3 --- /dev/null +++ b/apps/debug-frontend/src/sessions/session-api-groups.ts @@ -0,0 +1,161 @@ +import type { ShellSessionMode } from "./types"; + +export type SessionApiGroupStatus = "ready" | "planned"; + +export interface SessionApiEndpoint { + method: string; + path: string; +} + +export interface SessionApiGroup { + id: string; + title: string; + status: SessionApiGroupStatus; + reason?: string; + endpoints: SessionApiEndpoint[]; +} + +export const sessionApiGroups: Record = { + plan: [ + endpointGroup("plan-bootstrap", "Plan bootstrap and decision", [ + ["GET", "/api/plan"], + ["POST", "/api/approve"], + ["POST", "/api/deny"], + ]), + endpointGroup("plan-history", "Plan version history and diff", [ + ["GET", "/api/plan/version"], + ["GET", "/api/plan/versions"], + ["POST", "/api/plan/vscode-diff"], + ]), + endpointGroup("plan-archive-sidebar", "Archive browsing from plan mode", [ + ["GET", "/api/archive/plans"], + ["GET", "/api/archive/plan"], + ]), + ], + review: [ + endpointGroup("review-diff", "Diff bootstrap and switching", [ + ["GET", "/api/diff"], + ["POST", "/api/diff/switch"], + ["GET", "/api/file-content"], + ]), + endpointGroup("review-pr", "Pull request controls", [ + ["GET", "/api/pr-list"], + ["POST", "/api/pr-switch"], + ["POST", "/api/pr-diff-scope"], + ["GET", "/api/pr-context"], + ["POST", "/api/pr-action"], + ["POST", "/api/pr-viewed"], + ]), + endpointGroup("review-ai", "AI sessions and permissions", [ + ["GET", "/api/ai/capabilities"], + ["POST", "/api/ai/session"], + ["POST", "/api/ai/query"], + ["POST", "/api/ai/abort"], + ["POST", "/api/ai/permission"], + ["GET", "/api/ai/sessions"], + ]), + endpointGroup("review-code-nav", "Code navigation and staging", [ + ["POST", "/api/code-nav/resolve"], + ["GET", "/api/code-nav/file"], + ["POST", "/api/git-add"], + ]), + endpointGroup("review-tour", "Code tour result state", [ + ["GET", "/api/tour/:jobId"], + ["PUT", "/api/tour/:jobId/checklist"], + ]), + endpointGroup("review-submit", "Review feedback and exit", [ + ["POST", "/api/feedback"], + ["POST", "/api/exit"], + ]), + ], + annotate: [ + endpointGroup("annotate-bootstrap", "Annotate content bootstrap", [ + ["GET", "/api/plan"], + ["POST", "/api/feedback"], + ["POST", "/api/approve"], + ["POST", "/api/exit"], + ]), + endpointGroup("annotate-source", "Linked source browsing", [ + ["GET", "/api/doc"], + ["POST", "/api/doc/exists"], + ["GET", "/api/reference/files"], + ]), + ], + archive: [ + endpointGroup("archive-bootstrap", "Archive browse and close", [ + ["GET", "/api/plan"], + ["GET", "/api/archive/plans"], + ["GET", "/api/archive/plan"], + ["POST", "/api/done"], + ]), + ], + "setup-goal": [ + { + id: "setup-goal-contract", + title: "Setup-goal backend contract", + status: "planned", + reason: "The current source checkout does not expose setup-goal daemon endpoints yet.", + endpoints: [], + }, + ], + shared: [ + endpointGroup("external-annotations", "External annotation stream and CRUD", [ + ["GET", "/api/external-annotations/stream"], + ["GET", "/api/external-annotations"], + ["POST", "/api/external-annotations"], + ["PATCH", "/api/external-annotations"], + ["DELETE", "/api/external-annotations"], + ]), + endpointGroup("editor-annotations", "VS Code editor annotation bridge", [ + ["GET", "/api/editor-annotations"], + ["POST", "/api/editor-annotation"], + ["DELETE", "/api/editor-annotation"], + ]), + endpointGroup("agents", "Agent list and background jobs", [ + ["GET", "/api/agents"], + ["GET", "/api/agents/capabilities"], + ["GET", "/api/agents/jobs/stream"], + ["GET", "/api/agents/jobs"], + ["POST", "/api/agents/jobs"], + ["DELETE", "/api/agents/jobs"], + ["DELETE", "/api/agents/jobs/:id"], + ]), + endpointGroup("files", "Images, uploads, docs, config, and drafts", [ + ["GET", "/api/image"], + ["POST", "/api/upload"], + ["GET", "/api/doc"], + ["POST", "/api/doc/exists"], + ["POST", "/api/config"], + ["GET", "/api/draft"], + ["POST", "/api/draft"], + ["DELETE", "/api/draft"], + ]), + endpointGroup("notes", "Notes and reference integrations", [ + ["POST", "/api/save-notes"], + ["GET", "/api/obsidian/vaults"], + ["GET", "/api/reference/obsidian/files"], + ["GET", "/api/reference/obsidian/doc"], + ]), + ], +}; + +export function apiGroupsForMode(mode: ShellSessionMode): SessionApiGroup[] { + return sessionApiGroups[mode] ?? []; +} + +export function sharedApiGroups(): SessionApiGroup[] { + return sessionApiGroups.shared; +} + +function endpointGroup( + id: string, + title: string, + endpoints: Array<[method: string, path: string]>, +): SessionApiGroup { + return { + id, + title, + status: "ready", + endpoints: endpoints.map(([method, path]) => ({ method, path })), + }; +} diff --git a/apps/debug-frontend/src/sessions/session-id.test.ts b/apps/debug-frontend/src/sessions/session-id.test.ts new file mode 100644 index 000000000..b3d4bf248 --- /dev/null +++ b/apps/debug-frontend/src/sessions/session-id.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { encodeSessionId, isValidSessionId, parseSessionId } from "./session-id"; + +describe("session id validation", () => { + test("accepts daemon-style session ids", () => { + expect(isValidSessionId("plan-session-1")).toBe(true); + expect(isValidSessionId("review_ABC123")).toBe(true); + expect(parseSessionId("archive-session-4")).toBe("archive-session-4"); + }); + + test("rejects path-like or empty ids", () => { + expect(isValidSessionId("")).toBe(false); + expect(isValidSessionId("../plan-session-1")).toBe(false); + expect(isValidSessionId("plan/session")).toBe(false); + expect(parseSessionId("plan/session")).toBe(false); + }); + + test("throws before encoding invalid ids", () => { + expect(() => encodeSessionId("bad/session")).toThrow("Invalid Plannotator session id"); + }); +}); diff --git a/apps/debug-frontend/src/sessions/session-id.ts b/apps/debug-frontend/src/sessions/session-id.ts new file mode 100644 index 000000000..3fdf22bb0 --- /dev/null +++ b/apps/debug-frontend/src/sessions/session-id.ts @@ -0,0 +1,16 @@ +const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/; + +export function isValidSessionId(value: string): boolean { + return SESSION_ID_PATTERN.test(value); +} + +export function parseSessionId(value: string): string | false { + return isValidSessionId(value) ? value : false; +} + +export function encodeSessionId(value: string): string { + if (!isValidSessionId(value)) { + throw new Error(`Invalid Plannotator session id: ${value}`); + } + return encodeURIComponent(value); +} diff --git a/apps/debug-frontend/src/sessions/session-view-registry.test.tsx b/apps/debug-frontend/src/sessions/session-view-registry.test.tsx new file mode 100644 index 000000000..a2e3124ae --- /dev/null +++ b/apps/debug-frontend/src/sessions/session-view-registry.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { PLANNOTATOR_DAEMON_SESSION_VIEWS } from "@plannotator/shared/daemon-protocol"; +import { getSessionViewDefinition, supportedSessionModes } from "./session-view-registry"; + +describe("session view registry", () => { + test("registers every daemon-supported shell view", () => { + expect(supportedSessionModes().sort()).toEqual([...PLANNOTATOR_DAEMON_SESSION_VIEWS].sort()); + }); + + test("returns undefined for unsupported session modes", () => { + expect(getSessionViewDefinition("future-mode")).toBeUndefined(); + }); +}); diff --git a/apps/debug-frontend/src/sessions/session-view-registry.tsx b/apps/debug-frontend/src/sessions/session-view-registry.tsx new file mode 100644 index 000000000..cc9131063 --- /dev/null +++ b/apps/debug-frontend/src/sessions/session-view-registry.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { AnnotateSessionView } from "../annotate/AnnotateSessionView"; +import { ArchiveSessionView } from "../archive/ArchiveSessionView"; +import type { ShellSessionBootstrap, ShellSessionMode } from "./types"; +import { PlanSessionView } from "../plan/PlanSessionView"; +import { ReviewSessionView } from "../review/ReviewSessionView"; +import { SetupGoalSessionView } from "../setup-goal/SetupGoalSessionView"; + +export interface SessionViewComponentProps { + bootstrap: ShellSessionBootstrap; +} + +export type SessionViewComponent = (props: SessionViewComponentProps) => ReactNode; + +export interface SessionViewDefinition { + mode: ShellSessionMode; + title: string; + component: SessionViewComponent; +} + +const registry: Record = { + plan: { + mode: "plan", + title: "Plan review", + component: PlanSessionView, + }, + review: { + mode: "review", + title: "Code review", + component: ReviewSessionView, + }, + annotate: { + mode: "annotate", + title: "Annotate", + component: AnnotateSessionView, + }, + archive: { + mode: "archive", + title: "Archive", + component: ArchiveSessionView, + }, + "setup-goal": { + mode: "setup-goal", + title: "Setup goal", + component: SetupGoalSessionView, + }, +}; + +export function getSessionViewDefinition( + mode: ShellSessionMode, +): SessionViewDefinition | undefined { + return registry[mode]; +} + +export function supportedSessionModes(): ShellSessionMode[] { + return Object.keys(registry); +} diff --git a/apps/debug-frontend/src/sessions/state/session-store.test.ts b/apps/debug-frontend/src/sessions/state/session-store.test.ts new file mode 100644 index 000000000..63c5ce4c9 --- /dev/null +++ b/apps/debug-frontend/src/sessions/state/session-store.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; +import { sessionBootstrap, sessionListFixture } from "../../testing/fixtures/daemon"; +import { createSessionStore } from "./session-store"; + +describe("session store", () => { + test("keeps summaries keyed by session id", () => { + const store = createSessionStore(); + + store.getState().setSessions(sessionListFixture.sessions); + + expect(store.getState().sessionOrder).toEqual([ + "plan-session-1", + "review-session-2", + "annotate-session-3", + "archive-session-4", + "setup-goal-session-5", + ]); + expect(store.getState().sessions["review-session-2"].summary?.mode).toBe("review"); + }); + + test("updates bootstrap state without touching other sessions", () => { + const store = createSessionStore(); + store.getState().setSessions(sessionListFixture.sessions); + + store.getState().setBootstrap(sessionBootstrap("review", 2)); + + expect(store.getState().sessions["review-session-2"].loadState).toBe("ready"); + expect(store.getState().sessions["plan-session-1"].loadState).toBe("idle"); + }); + + test("uses Immer for nested status updates without mutating previous snapshots", () => { + const store = createSessionStore(); + store.getState().setBootstrap(sessionBootstrap("plan", 1)); + const previousBootstrap = store.getState().sessions["plan-session-1"].bootstrap; + + store.getState().setSessionStatus("plan-session-1", "completed"); + + expect(previousBootstrap?.session.status).toBe("active"); + expect(store.getState().sessions["plan-session-1"].bootstrap?.session.status).toBe("completed"); + }); +}); diff --git a/apps/debug-frontend/src/sessions/state/session-store.ts b/apps/debug-frontend/src/sessions/state/session-store.ts new file mode 100644 index 000000000..f8fb9f554 --- /dev/null +++ b/apps/debug-frontend/src/sessions/state/session-store.ts @@ -0,0 +1,135 @@ +import { createStore } from "zustand/vanilla"; +import { useStore } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import type { DaemonApiError } from "../../daemon/api/errors"; +import type { ShellSessionBootstrap, ShellSessionSummary } from "../types"; + +export type SessionLoadState = "idle" | "loading" | "ready" | "error"; + +export interface SessionRecordState { + loadState: SessionLoadState; + summary?: ShellSessionSummary; + bootstrap?: ShellSessionBootstrap; + error?: DaemonApiError; +} + +export interface SessionStoreState { + selectedSessionId?: string; + sessions: Record; + sessionOrder: string[]; +} + +export interface SessionStoreActions { + setSelectedSession(sessionId: string | undefined): void; + setSessions(sessions: ShellSessionSummary[]): void; + markLoading(sessionId: string): void; + setBootstrap(bootstrap: ShellSessionBootstrap): void; + setSessionError(sessionId: string, error: DaemonApiError): void; + setSessionStatus(sessionId: string, status: ShellSessionSummary["status"]): void; + removeSession(sessionId: string): void; + reset(): void; +} + +export type SessionStore = SessionStoreState & SessionStoreActions; + +const initialSessionState: SessionStoreState = { + selectedSessionId: undefined, + sessions: {}, + sessionOrder: [], +}; + +export function createSessionStore(initial: Partial = {}) { + return createStore()( + immer((set) => ({ + ...initialSessionState, + ...initial, + setSelectedSession(sessionId) { + set((state) => { + state.selectedSessionId = sessionId; + }); + }, + setSessions(sessions) { + set((state) => { + state.sessionOrder = sessions.map((session) => session.id); + for (const session of sessions) { + const existing = state.sessions[session.id]; + state.sessions[session.id] = { + loadState: existing?.loadState ?? "idle", + summary: session, + bootstrap: existing?.bootstrap, + error: undefined, + }; + } + + for (const id of Object.keys(state.sessions)) { + if (!state.sessionOrder.includes(id)) { + delete state.sessions[id]; + } + } + }); + }, + markLoading(sessionId) { + set((state) => { + state.sessions[sessionId] = { + ...state.sessions[sessionId], + loadState: "loading", + error: undefined, + }; + }); + }, + setBootstrap(bootstrap) { + set((state) => { + const id = bootstrap.session.id; + if (!state.sessionOrder.includes(id)) { + state.sessionOrder.push(id); + } + state.sessions[id] = { + loadState: "ready", + summary: bootstrap.session, + bootstrap, + error: undefined, + }; + }); + }, + setSessionError(sessionId, error) { + set((state) => { + state.sessions[sessionId] = { + ...state.sessions[sessionId], + loadState: "error", + error, + }; + }); + }, + setSessionStatus(sessionId, status) { + set((state) => { + const record = state.sessions[sessionId]; + if (!record) return; + if (record.summary) record.summary.status = status; + if (record.bootstrap) record.bootstrap.session.status = status; + }); + }, + removeSession(sessionId) { + set((state) => { + delete state.sessions[sessionId]; + state.sessionOrder = state.sessionOrder.filter((id) => id !== sessionId); + if (state.selectedSessionId === sessionId) { + state.selectedSessionId = undefined; + } + }); + }, + reset() { + set((state) => { + state.selectedSessionId = undefined; + state.sessions = {}; + state.sessionOrder = []; + }); + }, + })), + ); +} + +export const sessionStore = createSessionStore(); + +export function useSessionStore(selector: (state: SessionStore) => T): T { + return useStore(sessionStore, selector); +} diff --git a/apps/debug-frontend/src/sessions/types.ts b/apps/debug-frontend/src/sessions/types.ts new file mode 100644 index 000000000..b76e04b6a --- /dev/null +++ b/apps/debug-frontend/src/sessions/types.ts @@ -0,0 +1,8 @@ +export type { + ShellSessionBootstrap, + ShellSessionLifecycleStatus, + ShellSessionListResponse, + ShellSessionMode, + ShellSessionSummary, + ShellSessionView, +} from "../daemon/contracts"; diff --git a/apps/debug-frontend/src/setup-goal/SetupGoalSessionView.tsx b/apps/debug-frontend/src/setup-goal/SetupGoalSessionView.tsx new file mode 100644 index 000000000..1e8389255 --- /dev/null +++ b/apps/debug-frontend/src/setup-goal/SetupGoalSessionView.tsx @@ -0,0 +1,22 @@ +import { apiGroupsForMode } from "../sessions/session-api-groups"; +import { ApiGroupList } from "../shared/ui/ApiGroupList"; +import { SessionFacts } from "../shared/ui/SessionFacts"; +import type { SessionViewComponentProps } from "../sessions/session-view-registry"; + +export function SetupGoalSessionView({ bootstrap }: SessionViewComponentProps) { + return ( +
    +
    +

    Setup goal

    +

    {bootstrap.session.label}

    +

    + Fixture-backed shell view for future setup-goal interviews, fact sheets, plan generation, + and Plannotator review gates. The backend contract is planned until the source runtime + exposes it. +

    +
    + + +
    + ); +} diff --git a/apps/debug-frontend/src/shared/ui/ApiGroupList.tsx b/apps/debug-frontend/src/shared/ui/ApiGroupList.tsx new file mode 100644 index 000000000..a00d8fbd6 --- /dev/null +++ b/apps/debug-frontend/src/shared/ui/ApiGroupList.tsx @@ -0,0 +1,48 @@ +import type { SessionApiGroup } from "../../sessions/session-api-groups"; + +interface ApiGroupListProps { + groups: SessionApiGroup[]; +} + +const STATUS_LABELS: Record = { + planned: "Planned", + ready: "Ready", +}; + +export function ApiGroupList({ groups }: ApiGroupListProps) { + return ( +
    +
    +
    +

    Backend contract

    +

    Session API surface

    +
    + {groups.length} groups +
    +
    + {groups.map((group) => ( +
    +
    +

    {group.title}

    + + {STATUS_LABELS[group.status]} + +
    + {group.reason ?

    {group.reason}

    : null} + {group.endpoints.length > 0 ? ( +
      + {group.endpoints.slice(0, 8).map((endpoint) => ( +
    • + {endpoint.method} {endpoint.path} +
    • + ))} +
    + ) : ( +

    No endpoint contract in this checkout yet.

    + )} +
    + ))} +
    +
    + ); +} diff --git a/apps/debug-frontend/src/shared/ui/ResultNotice.tsx b/apps/debug-frontend/src/shared/ui/ResultNotice.tsx new file mode 100644 index 000000000..8ac0e57b5 --- /dev/null +++ b/apps/debug-frontend/src/shared/ui/ResultNotice.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { errorMessage, type DaemonApiError } from "../../daemon/api/errors"; + +interface ResultNoticeProps { + tone: "info" | "error" | "empty"; + title: string; + children?: ReactNode; + error?: DaemonApiError; +} + +export function ResultNotice({ tone, title, children, error }: ResultNoticeProps) { + return ( +
    +

    {title}

    + {error ?

    {errorMessage(error)}

    : children} +
    + ); +} diff --git a/apps/debug-frontend/src/shared/ui/SessionFacts.tsx b/apps/debug-frontend/src/shared/ui/SessionFacts.tsx new file mode 100644 index 000000000..eec7569c3 --- /dev/null +++ b/apps/debug-frontend/src/shared/ui/SessionFacts.tsx @@ -0,0 +1,38 @@ +import type { ShellSessionBootstrap } from "../../sessions/types"; + +interface SessionFactsProps { + bootstrap: ShellSessionBootstrap; +} + +export function SessionFacts({ bootstrap }: SessionFactsProps) { + const { session } = bootstrap; + + return ( +
    +
    +
    Session
    +
    {session.id}
    +
    +
    +
    Mode
    +
    {session.mode}
    +
    +
    +
    Project
    +
    {session.project}
    +
    +
    +
    Origin
    +
    {session.origin ?? "unknown"}
    +
    +
    +
    Status
    +
    {session.status}
    +
    +
    +
    API base
    +
    {bootstrap.apiBase}
    +
    +
    + ); +} diff --git a/apps/debug-frontend/src/styles.css b/apps/debug-frontend/src/styles.css new file mode 100644 index 000000000..50d1128af --- /dev/null +++ b/apps/debug-frontend/src/styles.css @@ -0,0 +1,515 @@ +@import "@plannotator/ui/theme.css"; + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; + margin: 0; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; + line-height: 1.5; + -webkit-user-select: text; + user-select: text; +} + +a { + color: inherit; +} + +code { + font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.92em; +} + +.muted-line { + color: var(--muted-foreground); + font-size: 0.84rem; +} + +/* Shell layout */ + +.app-shell { + min-height: 100vh; + background: color-mix(in srgb, var(--background) 96%, var(--muted)); +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 10px clamp(16px, 3vw, 36px); + border-bottom: 1px solid var(--border); + background: var(--background); +} + +.app-header h1 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0; +} + +.app-header nav { + display: flex; + gap: 12px; +} + +.app-header a { + padding: 6px 9px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted-foreground); + font-size: 0.88rem; + text-decoration: none; +} + +.app-header a[aria-current="page"] { + color: var(--foreground); + border-color: var(--ring); +} + +.app-main { + width: min(900px, calc(100vw - 28px)); + margin: 0 auto; + padding: 24px 0 40px; +} + +.app-shell button, +.app-shell summary, +.app-shell nav { + -webkit-user-select: none; + user-select: none; +} + +/* Page heading */ + +.page-heading { + margin-bottom: 20px; +} + +.page-heading h2 { + margin: 0; + font-size: 1.3rem; + letter-spacing: 0; +} + +.page-heading p { + margin: 4px 0 0; +} + +/* Status pills */ + +.status-pill { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 7px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted-foreground); + font-size: 0.75rem; + font-weight: 700; +} + +.status-active, +.status-ready, +.status-open, +.status-polling { + color: var(--success); + border-color: color-mix(in srgb, var(--success) 45%, var(--border)); +} + +.status-connecting, +.status-error { + color: var(--warning); + border-color: color-mix(in srgb, var(--warning) 45%, var(--border)); +} + +/* Notice / empty state */ + +.notice { + padding: 20px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); +} + +.notice p { + margin: 0; + color: var(--muted-foreground); +} + +.notice-error { + border-color: color-mix(in srgb, var(--destructive) 60%, var(--border)); +} + +/* Session list */ + +.session-list { + display: grid; + gap: 8px; + margin: 0 0 24px; + padding: 0; + list-style: none; +} + +.session-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px 16px; + align-items: center; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); +} + +.session-card-header { + display: flex; + align-items: center; + gap: 10px; + grid-column: 1; +} + +.session-card-label { + grid-column: 1; + overflow: hidden; + font-size: 0.9rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-card-id { + grid-column: 1; + color: var(--muted-foreground); + font-size: 0.78rem; +} + +.session-card-open { + grid-column: 2; + grid-row: 1 / 4; + display: inline-flex; + align-items: center; + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--primary); + color: var(--primary-foreground); + font-size: 0.88rem; + font-weight: 600; + text-decoration: none; +} + +.session-card-open:hover { + opacity: 0.9; +} + +/* Event log */ + +.event-log-section { + margin-top: 32px; +} + +.event-log-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.event-log-header h3 { + margin: 0; + font-size: 0.88rem; + color: var(--muted-foreground); +} + +.event-log { + margin: 0; + padding: 0; + list-style: none; + font-size: 0.8rem; + font-family: var(--font-mono), ui-monospace, monospace; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card); + max-height: 320px; + overflow-y: auto; +} + +.event-row { + display: flex; + gap: 10px; + padding: 5px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.event-row:last-child { + border-bottom: none; +} + +.event-row time { + color: var(--muted-foreground); + white-space: nowrap; + min-width: 9ch; +} + +.event-type { + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} + +.event-source { + color: var(--accent); + white-space: nowrap; +} + +.event-row code { + color: var(--muted-foreground); +} + +.event-row-debug { + color: var(--muted-foreground); +} + +/* Session debug panel */ + +.session-debug { + margin-top: 32px; + display: grid; + gap: 16px; +} + +.session-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + min-width: 120px; + padding: 9px 20px; + border: none; + border-radius: 8px; + font: inherit; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +.action-btn:disabled { + cursor: wait; + opacity: 0.5; +} + +.action-approve { + background: var(--success); + color: var(--success-foreground); +} + +.action-approve:hover:not(:disabled) { + opacity: 0.9; +} + +.action-deny { + background: var(--destructive); + color: var(--destructive-foreground); +} + +.action-deny:hover:not(:disabled) { + opacity: 0.9; +} + +.action-secondary { + background: var(--muted); + color: var(--foreground); + border: 1px solid var(--border); +} + +.debug-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.debug-actions button { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--background); + color: var(--muted-foreground); + font: inherit; + font-size: 0.82rem; + cursor: pointer; +} + +.debug-actions button:disabled { + cursor: wait; + opacity: 0.6; +} + +.debug-result { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card); +} + +.debug-result summary { + padding: 8px 12px; + cursor: pointer; + font-size: 0.84rem; + font-weight: 600; + color: var(--muted-foreground); +} + +.json-block { + max-height: 300px; + margin: 0; + overflow: auto; + padding: 10px 12px; + border-top: 1px solid var(--border); + background: var(--muted); + color: var(--muted-foreground); + font-size: 0.78rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* Session view (when viewing /s/:id) */ + +.session-panel { + padding: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); +} + +.session-panel header { + margin-bottom: 14px; +} + +.session-panel h2 { + margin: 0; + font-size: 1.2rem; + letter-spacing: 0; +} + +.session-panel header p { + margin: 6px 0 0; + color: var(--muted-foreground); +} + +.session-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 8px; + margin: 0 0 16px; +} + +.session-facts div { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--muted); +} + +.session-facts dt { + color: var(--muted-foreground); + font-size: 0.78rem; + font-weight: 700; +} + +.session-facts dd { + margin: 4px 0 0; + overflow-wrap: anywhere; +} + +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; +} + +.section-heading h2 { + margin: 0; + font-size: 1rem; + letter-spacing: 0; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--muted-foreground); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.api-group-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 8px; +} + +.api-group { + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--card) 80%, var(--muted)); +} + +.api-group h3 { + margin: 0; + font-size: 0.9rem; +} + +.api-group p, +.api-group li { + color: var(--muted-foreground); +} + +.api-group ul { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 4px 10px; + margin: 8px 0 0; + padding: 0; + list-style: none; + font-size: 0.8rem; +} + +.api-group p { + margin: 8px 0 0; +} + +@media (max-width: 720px) { + .app-header, + .page-heading { + align-items: stretch; + flex-direction: column; + } + + .app-main { + width: min(100% - 24px, 900px); + padding-top: 20px; + } +} diff --git a/apps/debug-frontend/src/testing/browser/render.tsx b/apps/debug-frontend/src/testing/browser/render.tsx new file mode 100644 index 000000000..1fd3de235 --- /dev/null +++ b/apps/debug-frontend/src/testing/browser/render.tsx @@ -0,0 +1,21 @@ +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +export async function renderBrowser( + ui: ReactNode, +): Promise<{ container: HTMLElement; root: Root }> { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render(ui); + }); + return { container, root }; +} + +export async function cleanupBrowser(root: Root, container: HTMLElement): Promise { + await act(async () => { + root.unmount(); + }); + container.remove(); +} diff --git a/apps/debug-frontend/src/testing/fetch.ts b/apps/debug-frontend/src/testing/fetch.ts new file mode 100644 index 000000000..4db8f5bd0 --- /dev/null +++ b/apps/debug-frontend/src/testing/fetch.ts @@ -0,0 +1,47 @@ +export interface RecordedRequest { + url: string; + init?: RequestInit; +} + +export interface FixtureFetch { + fetch: typeof fetch; + requests: RecordedRequest[]; +} + +export function createFixtureFetch(routes: Record): FixtureFetch { + const requests: RecordedRequest[] = []; + + const fixtureFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const normalized = normalizeFixtureUrl(url); + requests.push({ url: normalized, init }); + + const route = routes[normalized]; + if (route instanceof Response) return route; + if (route === undefined) { + return Response.json( + { + ok: false, + protocol: "plannotator-daemon", + protocolVersion: 1, + error: { + code: "session-not-found", + message: `No fixture route for ${normalized}`, + }, + }, + { status: 404 }, + ); + } + return Response.json(route); + }; + + return { fetch: fixtureFetch as typeof fetch, requests }; +} + +function normalizeFixtureUrl(url: string): string { + if (url.startsWith("http://") || url.startsWith("https://")) { + return new URL(url).pathname + new URL(url).search; + } + return url; +} diff --git a/apps/debug-frontend/src/testing/fixtures/daemon.test.ts b/apps/debug-frontend/src/testing/fixtures/daemon.test.ts new file mode 100644 index 000000000..f2e9a4f9f --- /dev/null +++ b/apps/debug-frontend/src/testing/fixtures/daemon.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; +import { PLANNOTATOR_DAEMON_SESSION_VIEWS } from "@plannotator/shared/daemon-protocol"; +import { sessionApiGroups } from "../../sessions/session-api-groups"; +import { sessionBootstraps, sessionListFixture } from "./daemon"; + +describe("daemon fixtures", () => { + test("include a bootstrap fixture for every supported shell view", () => { + for (const mode of PLANNOTATOR_DAEMON_SESSION_VIEWS) { + expect(sessionBootstraps[mode].session.mode).toBe(mode); + } + }); + + test("include dashboard sessions for every supported shell view", () => { + const modes = sessionListFixture.sessions.map((session) => session.mode).sort(); + expect(modes).toEqual([...PLANNOTATOR_DAEMON_SESSION_VIEWS].sort()); + }); + + test("represent every inventory API group with scoped API paths or explicit deferral", () => { + for (const [mode, groups] of Object.entries(sessionApiGroups)) { + expect(groups.length, `${mode} has groups`).toBeGreaterThan(0); + for (const group of groups) { + if (group.status === "planned") { + expect(group.reason).toBeTruthy(); + continue; + } + expect(group.endpoints.length, `${group.id} has endpoints`).toBeGreaterThan(0); + for (const endpoint of group.endpoints) { + expect(endpoint.path.startsWith("/api/") || endpoint.path === "/favicon.svg").toBe(true); + } + } + } + }); +}); diff --git a/apps/debug-frontend/src/testing/fixtures/daemon.ts b/apps/debug-frontend/src/testing/fixtures/daemon.ts new file mode 100644 index 000000000..b71ccafa9 --- /dev/null +++ b/apps/debug-frontend/src/testing/fixtures/daemon.ts @@ -0,0 +1,99 @@ +import { + PLANNOTATOR_DAEMON_SESSION_VIEWS, + getDaemonCapabilities, +} from "@plannotator/shared/daemon-protocol"; +import type { + ShellDaemonStatus, + ShellSessionBootstrap, + ShellSessionListResponse, + ShellSessionMode, + ShellSessionSummary, +} from "../../daemon/contracts"; + +const now = "2026-05-17T12:00:00.000Z"; + +export const daemonCapabilities = getDaemonCapabilities(); + +export const daemonStatusFixture: ShellDaemonStatus = { + ok: true, + protocol: "plannotator-daemon", + protocolVersion: 1, + pid: 4242, + endpoint: { + hostname: "127.0.0.1", + port: 19432, + baseUrl: "http://127.0.0.1:19432", + isRemote: false, + }, + startedAt: now, + activeSessionCount: 5, + sessionCount: 5, +}; + +export function sessionSummary(mode: ShellSessionMode, index = 1): ShellSessionSummary { + return { + id: `${mode}-session-${index}`, + mode, + status: "active", + url: `http://127.0.0.1:19432/s/${mode}-session-${index}`, + project: mode === "archive" ? "Personal archive" : "plannotator", + label: sessionLabel(mode), + origin: index % 2 === 0 ? "opencode" : "claude-code", + createdAt: now, + updatedAt: now, + expiresAt: "2026-05-21T12:00:00.000Z", + }; +} + +function sessionLabel(mode: ShellSessionMode): string { + switch (mode) { + case "plan": + return "Runtime frontend shell plan"; + case "review": + return "PR #734 daemon runtime review"; + case "annotate": + return "Annotate docs/runtime.md"; + case "archive": + return "Plan decision archive"; + case "setup-goal": + return "Setup goal interview"; + default: + return `Unsupported ${mode}`; + } +} + +export const sessionListFixture: ShellSessionListResponse = { + ok: true, + sessions: [ + sessionSummary("plan", 1), + sessionSummary("review", 2), + sessionSummary("annotate", 3), + sessionSummary("archive", 4), + sessionSummary("setup-goal", 5), + ], +}; + +export function sessionBootstrap(mode: ShellSessionMode, index = 1): ShellSessionBootstrap { + const session = sessionSummary(mode, index); + return { + ok: true, + session, + apiBase: `/s/${session.id}/api`, + capabilities: daemonCapabilities, + supportedSessionViews: [...PLANNOTATOR_DAEMON_SESSION_VIEWS], + }; +} + +export const sessionBootstraps = { + plan: sessionBootstrap("plan", 1), + review: sessionBootstrap("review", 2), + annotate: sessionBootstrap("annotate", 3), + archive: sessionBootstrap("archive", 4), + "setup-goal": sessionBootstrap("setup-goal", 5), + unsupported: sessionBootstrap("unknown-mode", 6), +} as const; + +export const emptySessionListFixture: ShellSessionListResponse = { + ok: true, + sessions: [], +}; diff --git a/apps/debug-frontend/tsconfig.json b/apps/debug-frontend/tsconfig.json new file mode 100644 index 000000000..07549d8b7 --- /dev/null +++ b/apps/debug-frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "jsx": "react-jsx", + "types": ["node", "vitest/globals"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@plannotator/shared/*": ["../../packages/shared/*"], + "@plannotator/ui/*": ["../../packages/ui/*"], + }, + }, + "include": ["src", "scripts", "vite.config.ts", "vitest.config.ts", "vitest.browser.config.ts"], +} diff --git a/apps/debug-frontend/vite.config.ts b/apps/debug-frontend/vite.config.ts new file mode 100644 index 000000000..e42db6c2a --- /dev/null +++ b/apps/debug-frontend/vite.config.ts @@ -0,0 +1,42 @@ +import path from "path"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + server: { + port: 3002, + host: "0.0.0.0", + }, + plugins: [ + tanstackRouter({ + target: "react", + routesDirectory: "./src/routes", + generatedRouteTree: "./src/routeTree.gen.ts", + quoteStyle: "double", + }), + react(), + tailwindcss(), + viteSingleFile(), + ], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + "@plannotator/shared": path.resolve(__dirname, "../../packages/shared"), + "@plannotator/ui": path.resolve(__dirname, "../../packages/ui"), + }, + }, + build: { + target: "esnext", + assetsInlineLimit: 100000000, + chunkSizeWarningLimit: 100000000, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +}); diff --git a/apps/debug-frontend/vitest.browser.config.ts b/apps/debug-frontend/vitest.browser.config.ts new file mode 100644 index 000000000..1157f9de8 --- /dev/null +++ b/apps/debug-frontend/vitest.browser.config.ts @@ -0,0 +1,24 @@ +import path from "path"; +import { playwright } from "@vitest/browser-playwright"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + "@plannotator/shared": path.resolve(__dirname, "../../packages/shared"), + "@plannotator/ui": path.resolve(__dirname, "../../packages/ui"), + }, + }, + test: { + include: ["src/**/*.browser.tsx"], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + }, + }, +}); diff --git a/apps/debug-frontend/vitest.config.ts b/apps/debug-frontend/vitest.config.ts new file mode 100644 index 000000000..cfa19d676 --- /dev/null +++ b/apps/debug-frontend/vitest.config.ts @@ -0,0 +1,19 @@ +import path from "path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + "@plannotator/shared": path.resolve(__dirname, "../../packages/shared"), + "@plannotator/ui": path.resolve(__dirname, "../../packages/ui"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + exclude: ["src/**/*.browser.test.tsx", "src/routeTree.gen.ts"], + }, +}); diff --git a/apps/debug-tui/README.md b/apps/debug-tui/README.md new file mode 100644 index 000000000..064b3c961 --- /dev/null +++ b/apps/debug-tui/README.md @@ -0,0 +1,36 @@ +# @plannotator/debug-tui + +Terminal UI agent simulator for testing daemon sessions. **Not production code** — this exercises +Plannotator's real agent protocols against the daemon runtime using local fixtures. + +The simulator spawns the same `plannotator` commands that agents use, writes realistic stdin +payloads, captures stdout/stderr, watches for `PLANNOTATOR_SESSION_READY`, and can complete sessions +through the daemon-scoped browser API. Multiple sessions can run concurrently. + +## Commands + +```bash +bun run --cwd apps/debug-tui start +bun run --cwd apps/debug-tui run -- --scenario opencode-plan +bun run --cwd apps/debug-tui test:e2e +``` + +Or from the repo root: + +```bash +bun run dev:debug-tui +bun run check:debug-tui +bun run dev:debug-stack # starts daemon + opens browser + launches TUI +``` + +## TUI keys + +- `Enter` — start selected scenario +- `a` — start all scenarios concurrently +- `m` — toggle manual/auto-complete mode +- `c` — copy latest log to clipboard +- `p` — copy log file path +- `q` — quit + +Fixtures are local: temporary workspaces, local git repositories, markdown/html files, and archived +plan files. External hosted agents, live PR URLs, and hosted sharing services are not required. diff --git a/apps/debug-tui/package.json b/apps/debug-tui/package.json new file mode 100644 index 000000000..f6b54b25f --- /dev/null +++ b/apps/debug-tui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@plannotator/debug-tui", + "description": "Terminal UI agent simulator for testing daemon sessions. Not production code.", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "bun run src/main.tsx", + "run": "bun run src/cli.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run", + "test:e2e": "PLANNOTATOR_AGENT_SIMULATOR_E2E=1 vitest run src/e2e/full-loop.test.ts", + "check": "bun run typecheck && bun run test" + }, + "dependencies": { + "@opentui/core": "0.2.6", + "@opentui/react": "0.2.6", + "@plannotator/shared": "workspace:*", + "react": "^19.2.3" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "typescript": "~5.8.2", + "vitest": "^4.0.16" + } +} diff --git a/apps/debug-tui/src/cli.ts b/apps/debug-tui/src/cli.ts new file mode 100644 index 000000000..c39128fda --- /dev/null +++ b/apps/debug-tui/src/cli.ts @@ -0,0 +1,66 @@ +import { fileURLToPath } from "node:url"; +import { authTokenFromBrowserUrl } from "./daemon/client"; +import { getScenario, scenarioDefinitions } from "./scenarios"; +import { runScenario } from "./scenarios/run-scenario"; +import type { ScenarioId } from "./scenarios/types"; + +const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const args = process.argv.slice(2); + +if (args.includes("--list")) { + for (const scenario of scenarioDefinitions) { + console.log(`${scenario.id}\t${scenario.agent}\t${scenario.title}`); + } + process.exit(0); +} + +const scenarioId = readArg("--scenario") as ScenarioId | undefined; +const runAll = args.includes("--all"); +const json = args.includes("--json"); +const manual = args.includes("--manual"); +const sharedDaemon = args.includes("--shared-daemon"); + +if (!scenarioId && !runAll) { + console.error("Usage: bun run src/cli.ts -- --scenario [--json] [--manual] [--shared-daemon]"); + console.error(" bun run src/cli.ts -- --all [--json]"); + console.error(" bun run src/cli.ts -- --list"); + process.exit(1); +} + +const selected = runAll ? scenarioDefinitions : [getScenario(scenarioId as ScenarioId)]; +const results = []; + +for (const scenario of selected) { + const result = await runScenario(scenario, { + repoRoot, + completion: manual ? "manual" : "auto", + useSharedDaemon: sharedDaemon, + stopDaemonOnFinish: !sharedDaemon, + daemonBaseUrl: process.env.PLANNOTATOR_SIMULATOR_DAEMON_URL, + daemonAuthToken: authTokenFromBrowserUrl(process.env.PLANNOTATOR_SIMULATOR_DAEMON_BROWSER_URL), + onLog: (entry) => { + if (!json) console.error(`[${scenario.id}] ${entry.message}`); + }, + }); + results.push({ + id: scenario.id, + exitCode: result.process.exitCode, + session: result.session, + stdout: result.process.stdout, + stderr: result.process.stderr, + }); +} + +if (json) { + console.log(JSON.stringify({ ok: true, results }, null, 2)); +} else { + for (const result of results) { + console.log(`${result.id}: exit=${result.exitCode} session=${result.session?.url ?? "none"}`); + } +} + +function readArg(name: string): string | undefined { + const index = args.indexOf(name); + if (index === -1) return undefined; + return args[index + 1]; +} diff --git a/apps/debug-tui/src/clipboard.test.ts b/apps/debug-tui/src/clipboard.test.ts new file mode 100644 index 000000000..6bdf4e1d8 --- /dev/null +++ b/apps/debug-tui/src/clipboard.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { clipboardCandidates } from "./clipboard"; + +describe("agent simulator clipboard candidates", () => { + test("uses pbcopy on macOS", () => { + expect(clipboardCandidates("darwin")).toEqual([{ command: "pbcopy", args: [] }]); + }); + + test("uses cmd clip on Windows", () => { + expect(clipboardCandidates("win32")).toEqual([{ command: "cmd", args: ["/c", "clip"] }]); + }); + + test("tries common Linux clipboard commands", () => { + expect(clipboardCandidates("linux").map((candidate) => candidate.command)).toEqual([ + "wl-copy", + "xclip", + "xsel", + ]); + }); +}); diff --git a/apps/debug-tui/src/clipboard.ts b/apps/debug-tui/src/clipboard.ts new file mode 100644 index 000000000..dbff72d22 --- /dev/null +++ b/apps/debug-tui/src/clipboard.ts @@ -0,0 +1,71 @@ +import { spawn } from "node:child_process"; + +export interface ClipboardResult { + ok: boolean; + message: string; +} + +interface ClipboardCommand { + command: string; + args: string[]; +} + +export function clipboardCandidates(platform: NodeJS.Platform = process.platform): ClipboardCommand[] { + if (platform === "darwin") return [{ command: "pbcopy", args: [] }]; + if (platform === "win32") return [{ command: "cmd", args: ["/c", "clip"] }]; + return [ + { command: "wl-copy", args: [] }, + { command: "xclip", args: ["-selection", "clipboard"] }, + { command: "xsel", args: ["--clipboard", "--input"] }, + ]; +} + +export async function copyTextToClipboard(text: string): Promise { + if (!text) return { ok: false, message: "Nothing to copy." }; + + const errors: string[] = []; + for (const candidate of clipboardCandidates()) { + const result = await runClipboardCommand(candidate, text); + if (result.ok) return { ok: true, message: "Copied to clipboard." }; + errors.push(result.message); + } + + return { + ok: false, + message: `Clipboard copy failed: ${errors.filter(Boolean).join("; ") || "no clipboard command found"}`, + }; +} + +function runClipboardCommand(candidate: ClipboardCommand, text: string): Promise { + return new Promise((resolve) => { + const child = spawn(candidate.command, candidate.args, { + stdio: ["pipe", "ignore", "pipe"], + }); + let stderr = ""; + let settled = false; + + const settle = (result: ClipboardResult) => { + if (settled) return; + settled = true; + resolve(result); + }; + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", (err: NodeJS.ErrnoException) => { + settle({ + ok: false, + message: err.code === "ENOENT" ? `${candidate.command} not found` : err.message, + }); + }); + child.on("close", (code) => { + settle({ + ok: code === 0, + message: code === 0 ? "copied" : `${candidate.command} exited ${code}: ${stderr.trim()}`, + }); + }); + child.stdin.end(text); + }); +} diff --git a/apps/debug-tui/src/daemon/client.test.ts b/apps/debug-tui/src/daemon/client.test.ts new file mode 100644 index 000000000..31abc3df6 --- /dev/null +++ b/apps/debug-tui/src/daemon/client.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import { completeSession, createSimulatorDaemonClient } from "./client"; + +describe("simulator daemon completion client", () => { + test.each([ + ["plan", "/api/approve", { planSave: { enabled: false } }], + ["review", "/api/feedback", { approved: true, feedback: "LGTM", annotations: [] }], + ["annotate", "/api/approve", {}], + ["archive", "/api/done", {}], + ] as const)("completes %s sessions through the session API", async (mode, path, body) => { + const requests: { url: string; init?: RequestInit }[] = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + requests.push({ url, init }); + return Response.json({ ok: true }); + }) as typeof fetch; + + await completeSession(fetchImpl, "http://127.0.0.1:19432/s/session-1", mode); + + expect(requests[0].url).toBe(`http://127.0.0.1:19432/s/session-1${path}`); + expect(requests[0].init?.method).toBe("POST"); + expect(JSON.parse(String(requests[0].init?.body))).toEqual(body); + }); + + test("posts debug logs to the daemon event stream", async () => { + const requests: { url: string; init?: RequestInit }[] = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + requests.push({ url, init }); + return Response.json({ ok: true }); + }) as typeof fetch; + + await createSimulatorDaemonClient("http://127.0.0.1:19432", fetchImpl, { authToken: "secret" }).postDebugLog({ + source: "agent-simulator", + scenarioId: "claude-plan-hook", + message: "queued claude-plan-hook", + }); + + expect(requests[0].url).toBe("http://127.0.0.1:19432/daemon/events/debug"); + expect(requests[0].init?.method).toBe("POST"); + expect(new Headers(requests[0].init?.headers).get("authorization")).toBe("Bearer secret"); + expect(JSON.parse(String(requests[0].init?.body))).toEqual({ + source: "agent-simulator", + scenarioId: "claude-plan-hook", + message: "queued claude-plan-hook", + }); + }); +}); diff --git a/apps/debug-tui/src/daemon/client.ts b/apps/debug-tui/src/daemon/client.ts new file mode 100644 index 000000000..6e5c9d2f8 --- /dev/null +++ b/apps/debug-tui/src/daemon/client.ts @@ -0,0 +1,144 @@ +import type { DaemonStatus } from "@plannotator/shared/daemon-protocol"; + +export interface SimulatorDaemonSession { + id: string; + mode: "plan" | "review" | "annotate" | "archive" | string; + status: string; + url: string; + project: string; + label: string; +} + +export interface SimulatorDaemonClient { + getStatus(): Promise; + listSessions(): Promise; + postDebugLog(event: SimulatorDebugLogEvent): Promise; + completeSession( + session: { url: string; mode: string }, + completion: "plan" | "review" | "annotate" | "archive", + ): Promise; + shutdown(): Promise; +} + +export interface SimulatorDebugLogEvent { + at?: string; + source: string; + scenarioId?: string; + message: string; + level?: "debug" | "info" | "warn" | "error"; + data?: unknown; +} + +export interface SimulatorDaemonClientOptions { + authToken?: string; +} + +export function createSimulatorDaemonClient( + baseUrl: string, + fetchImpl: typeof fetch = fetch, + options: SimulatorDaemonClientOptions = {}, +): SimulatorDaemonClient { + const daemonInit = (init?: RequestInit): RequestInit => withDaemonAuth(init, options.authToken); + + return { + async getStatus() { + return readJson(fetchImpl, `${baseUrl}/daemon/status`, daemonInit()); + }, + + async listSessions() { + const payload = await readJson<{ ok: true; sessions: SimulatorDaemonSession[] }>( + fetchImpl, + `${baseUrl}/daemon/sessions?clean=1`, + daemonInit(), + ); + return payload.sessions; + }, + + async postDebugLog(event) { + await readJson(fetchImpl, `${baseUrl}/daemon/events/debug`, daemonInit({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + })); + }, + + completeSession(session, completion) { + return completeSession(fetchImpl, session.url, completion); + }, + + async shutdown() { + await readJson(fetchImpl, `${baseUrl}/daemon/shutdown`, daemonInit({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + })); + }, + }; +} + +export function authTokenFromBrowserUrl(browserUrl: string | undefined): string | undefined { + if (!browserUrl) return undefined; + try { + return new URL(browserUrl).searchParams.get("plannotator_auth") ?? undefined; + } catch { + return undefined; + } +} + +export async function completeSession( + fetchImpl: typeof fetch, + sessionUrl: string, + completion: "plan" | "review" | "annotate" | "archive" | "goal-setup", +): Promise { + switch (completion) { + case "plan": + return readJson(fetchImpl, `${sessionUrl}/api/approve`, postJson({ planSave: { enabled: false } })); + case "review": + return readJson( + fetchImpl, + `${sessionUrl}/api/feedback`, + postJson({ approved: true, feedback: "LGTM", annotations: [] }), + ); + case "annotate": + return readJson(fetchImpl, `${sessionUrl}/api/approve`, postJson({})); + case "archive": + return readJson(fetchImpl, `${sessionUrl}/api/done`, postJson({})); + case "goal-setup": + return readJson( + fetchImpl, + `${sessionUrl}/api/goal-setup/submit`, + postJson({ answers: [], facts: [] }), + ); + } +} + +function postJson(body: unknown): RequestInit { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +function withDaemonAuth(init: RequestInit = {}, authToken?: string): RequestInit { + if (!authToken) return init; + const headers = new Headers(init.headers); + if (!headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${authToken}`); + } + return { ...init, headers }; +} + +async function readJson( + fetchImpl: typeof fetch, + url: string, + init?: RequestInit, +): Promise { + const response = await fetchImpl(url, init); + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + if (!response.ok) { + throw new Error(`Daemon request failed (${response.status}): ${text}`); + } + return payload as T; +} diff --git a/apps/debug-tui/src/e2e/full-loop.test.ts b/apps/debug-tui/src/e2e/full-loop.test.ts new file mode 100644 index 000000000..ec15f220d --- /dev/null +++ b/apps/debug-tui/src/e2e/full-loop.test.ts @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { parsePluginResponse } from "@plannotator/shared/plugin-protocol"; +import { getScenario } from "../scenarios"; +import { runScenario } from "../scenarios/run-scenario"; + +const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); + +describe.skipIf(process.env.PLANNOTATOR_AGENT_SIMULATOR_E2E !== "1")( + "agent simulator full process-to-daemon loop", + () => { + test( + "spawns the real Plannotator plugin command, creates a daemon plan session, and approves it", + async () => { + await buildFrontendShell(); + const result = await runScenario(getScenario("opencode-plan"), { + repoRoot, + timeoutMs: 120_000, + }); + + expect(result.session?.mode).toBe("plan"); + expect(result.process.exitCode).toBe(0); + const response = parsePluginResponse(result.process.stdout.trim()); + expect(response?.ok).toBe(true); + expect(response?.ok === true ? response.result : undefined).toMatchObject({ + approved: true, + }); + }, + 180_000, + ); + }, +); + +async function buildFrontendShell(): Promise { + const child = spawn("bun", ["run", "--cwd", "apps/debug-frontend", "build"], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + output += chunk; + }); + child.stderr.on("data", (chunk: string) => { + output += chunk; + }); + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + if (code !== 0) { + throw new Error(`frontend build failed:\n${output}`); + } +} diff --git a/apps/debug-tui/src/logging/run-log.ts b/apps/debug-tui/src/logging/run-log.ts new file mode 100644 index 000000000..885d39f2f --- /dev/null +++ b/apps/debug-tui/src/logging/run-log.ts @@ -0,0 +1,62 @@ +import { appendFile, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ScenarioRunLog, ScenarioRunResult } from "../scenarios/run-scenario"; +import type { ScenarioDefinition } from "../scenarios/types"; + +export interface SimulatorRunLog { + path: string; + latestPath: string; + append(entry: ScenarioRunLog): Promise; + appendText(text: string): Promise; + appendResult(result: ScenarioRunResult): Promise; +} + +export async function createSimulatorRunLog( + repoRoot: string, + scenario: ScenarioDefinition, +): Promise { + const dir = join(repoRoot, "plannotator-local", "simulator-runs"); + await mkdir(dir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const path = join(dir, `${stamp}-${scenario.id}.log`); + const latestPath = join(dir, "latest.log"); + const header = [ + `scenario=${scenario.id}`, + `agent=${scenario.agent}`, + `kind=${scenario.kind}`, + `session=${scenario.expectedSessionMode}`, + "", + ].join("\n"); + await Promise.all([writeFile(path, header), writeFile(latestPath, header)]); + + const appendText = async (text: string) => { + await Promise.all([appendFile(path, text), appendFile(latestPath, text)]); + }; + + return { + path, + latestPath, + append(entry) { + return appendText(`${entry.at} ${entry.message}\n`); + }, + appendText, + appendResult(result) { + return appendText( + [ + "", + `exitCode=${result.process.exitCode}`, + `signal=${result.process.signal ?? ""}`, + `timedOut=${result.process.timedOut}`, + `session=${result.session?.url ?? ""}`, + "", + "stdout:", + result.process.stdout, + "", + "stderr:", + result.process.stderr, + "", + ].join("\n"), + ); + }, + }; +} diff --git a/apps/debug-tui/src/main.tsx b/apps/debug-tui/src/main.tsx new file mode 100644 index 000000000..008721438 --- /dev/null +++ b/apps/debug-tui/src/main.tsx @@ -0,0 +1,246 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createCliRenderer } from "@opentui/core"; +import { createRoot, useKeyboard, useRenderer } from "@opentui/react"; +import { useMemo, useState } from "react"; +import type React from "react"; +import { copyTextToClipboard } from "./clipboard"; +import { authTokenFromBrowserUrl, createSimulatorDaemonClient } from "./daemon/client"; +import { createSimulatorRunLog } from "./logging/run-log"; +import { scenarioDefinitions } from "./scenarios"; +import { runScenario, type ScenarioRunLog } from "./scenarios/run-scenario"; +import type { ScenarioDefinition } from "./scenarios/types"; + +const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const defaultLatestLogPath = join(repoRoot, "plannotator-local", "simulator-runs", "latest.log"); +const daemonBaseUrl = process.env.PLANNOTATOR_SIMULATOR_DAEMON_URL; +const daemonAuthToken = authTokenFromBrowserUrl(process.env.PLANNOTATOR_SIMULATOR_DAEMON_BROWSER_URL); + +function SimulatorApp() { + const renderer = useRenderer(); + const [selectedIndex, setSelectedIndex] = useState(0); + const [runningIds, setRunningIds] = useState>(new Set()); + const [autoComplete, setAutoComplete] = useState(false); + const [logPath, setLogPath] = useState(defaultLatestLogPath); + const [copyStatus, setCopyStatus] = useState("c copies log, p copies log path"); + const [logs, setLogs] = useState([]); + const selected = scenarioDefinitions[selectedIndex] ?? scenarioDefinitions[0]; + + useKeyboard((key) => { + if (key.name === "q" || (key.ctrl && key.name === "c")) { + renderer.stop(); + return; + } + if (key.name === "c") { + void copyLogFile(logPath, setCopyStatus); + return; + } + if (key.name === "p") { + void copyPlainText(logPath, "Copied log path.", setCopyStatus); + return; + } + if (key.name === "m") { + setAutoComplete((value) => !value); + } + if (key.name === "up" || key.name === "k") { + setSelectedIndex((index) => Math.max(0, index - 1)); + } + if (key.name === "down" || key.name === "j") { + setSelectedIndex((index) => Math.min(scenarioDefinitions.length - 1, index + 1)); + } + if (key.name === "return") { + if (runningIds.has(selected.id)) return; + void runSelected(selected, { autoComplete, setRunningIds, setLogs, setLogPath }); + } + if (key.name === "a") { + const notRunning = scenarioDefinitions.filter((s) => !runningIds.has(s.id)); + for (const scenario of notRunning) { + void runSelected(scenario, { autoComplete, setRunningIds, setLogs, setLogPath }); + } + } + }); + + const recentLogs = useMemo(() => logs.slice(-16), [logs]); + + return ( + + Plannotator Agent Simulator + + Enter starts scenario. a starts all. m toggles manual/auto. c copies logs. q exits. + + + mode={autoComplete ? "auto-complete" : "manual"} running={runningIds.size} daemon= + {daemonBaseUrl ?? "discover"} + + + {copyStatus} + + + + {scenarioDefinitions.map((scenario, index) => ( + + ))} + + + {selected.title} + {selected.description} + + agent={selected.agent} kind={selected.kind} session={selected.expectedSessionMode} + + Logs ({logs.length}) + {recentLogs.map((log) => ( + + {formatLogTime(log.at)} {log.message} + + ))} + + + + ); +} + +function ScenarioRow({ + scenario, + selected, + running, +}: { + scenario: ScenarioDefinition; + selected: boolean; + running: boolean; +}) { + const marker = running ? ">" : selected ? "*" : " "; + const color = running ? "#fbbf24" : selected ? "#ffffff" : "#d1d5db"; + return ( + + {marker} {scenario.id} + + ); +} + +async function runSelected( + scenario: ScenarioDefinition, + options: { + autoComplete: boolean; + setRunningIds: React.Dispatch>>; + setLogs: React.Dispatch>; + setLogPath: (path: string) => void; + }, +): Promise { + options.setRunningIds((ids) => new Set([...ids, scenario.id])); + const runLog = await createSimulatorRunLog(repoRoot, scenario); + options.setLogPath(runLog.latestPath); + const queued = { at: new Date().toISOString(), message: `queued ${scenario.id}` }; + await runLog.append(queued); + options.setLogs((logs) => [...logs, queued]); + void forwardLogToDaemon(scenario.id, queued); + try { + const result = await runScenario(scenario, { + repoRoot, + completion: options.autoComplete ? "auto" : "manual", + useSharedDaemon: true, + stopDaemonOnFinish: false, + timeoutMs: 10 * 60_000, + daemonBaseUrl, + daemonAuthToken, + onLog: (entry) => { + void runLog.append(entry); + options.setLogs((logs) => [...logs, entry]); + }, + }); + await runLog.appendResult(result); + options.setLogs((logs) => [ + ...logs, + { + at: new Date().toISOString(), + message: `finished ${scenario.id} exit=${result.process.exitCode}`, + }, + ]); + } catch (err) { + const entry = { + at: new Date().toISOString(), + message: `[${scenario.id}] ${err instanceof Error ? err.message : "scenario failed"}`, + }; + await runLog.append(entry); + options.setLogs((logs) => [...logs, entry]); + } finally { + options.setRunningIds((ids) => { + const next = new Set(ids); + next.delete(scenario.id); + return next; + }); + } +} + +async function forwardLogToDaemon(scenarioId: string, entry: ScenarioRunLog): Promise { + if (!daemonBaseUrl) return; + try { + await createSimulatorDaemonClient(daemonBaseUrl, fetch, { authToken: daemonAuthToken }).postDebugLog({ + at: entry.at, + source: "agent-simulator", + scenarioId, + message: entry.message, + level: "info", + }); + } catch { + // The local run log remains the source of truth if debug forwarding is unavailable. + } +} + +async function copyLogFile( + path: string, + setCopyStatus: (message: string) => void, +): Promise { + try { + const text = await readFile(path, "utf8"); + await copyPlainText(text, "Copied latest log.", setCopyStatus); + } catch (err) { + setCopyStatus(err instanceof Error ? `Copy failed: ${err.message}` : "Copy failed."); + } +} + +async function copyPlainText( + text: string, + successMessage: string, + setCopyStatus: (message: string) => void, +): Promise { + const result = await copyTextToClipboard(text); + setCopyStatus(result.ok ? successMessage : result.message); +} + +function formatLogTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ exitOnCtrlC: true }); + createRoot(renderer).render(); +} diff --git a/apps/debug-tui/src/process/run-plannotator.test.ts b/apps/debug-tui/src/process/run-plannotator.test.ts new file mode 100644 index 000000000..1cfdce2f0 --- /dev/null +++ b/apps/debug-tui/src/process/run-plannotator.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { runPlannotatorCommand } from "./run-plannotator"; +import { parseSessionReadyLine } from "./session-ready"; + +describe("Plannotator process runner", () => { + test("parses PLANNOTATOR_SESSION_READY from stderr", () => { + const session = parseSessionReadyLine( + 'PLANNOTATOR_SESSION_READY {"mode":"plan","url":"http://127.0.0.1:19432/s/a","port":19432,"isRemote":false}', + ); + + expect(session?.mode).toBe("plan"); + expect(session?.url).toContain("/s/a"); + }); + + test("captures stdout, stderr, exit status, and session readiness", async () => { + const dir = await mkdtemp(join(tmpdir(), "plannotator-process-")); + const script = join(dir, "child.ts"); + await writeFile( + script, + ` +console.error('progress line'); +console.error('PLANNOTATOR_SESSION_READY {"mode":"review","url":"http://127.0.0.1:19432/s/review","port":19432,"isRemote":false}'); +console.log('{"ok":true}'); +`, + ); + + const result = await runPlannotatorCommand({ + command: process.execPath, + args: [script], + cwd: dir, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('"ok":true'); + expect(result.stderr).toContain("progress line"); + expect(result.session?.mode).toBe("review"); + }); +}); diff --git a/apps/debug-tui/src/process/run-plannotator.ts b/apps/debug-tui/src/process/run-plannotator.ts new file mode 100644 index 000000000..249e4cee8 --- /dev/null +++ b/apps/debug-tui/src/process/run-plannotator.ts @@ -0,0 +1,92 @@ +import { spawn } from "node:child_process"; +import type { PluginSessionInfo } from "@plannotator/shared/plugin-protocol"; +import type { ScenarioCommand } from "../scenarios/types"; +import { parseSessionReadyLine } from "./session-ready"; + +export interface ProcessRunResult { + command: ScenarioCommand; + stdout: string; + stderr: string; + exitCode: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; + session?: PluginSessionInfo; +} + +export interface ProcessRunOptions { + timeoutMs?: number; + onSessionReady?: (session: PluginSessionInfo) => void; + onLog?: (line: string) => void; +} + +export function runPlannotatorCommand( + command: ScenarioCommand, + options: ProcessRunOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 120_000; + const child = spawn(command.command, command.args, { + cwd: command.cwd, + env: { ...process.env, ...command.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let pendingStderr = ""; + let session: PluginSessionInfo | undefined; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + const killTimer = setTimeout(() => child.kill("SIGKILL"), 1_000); + killTimer.unref?.(); + }, timeoutMs); + timeout.unref?.(); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + pendingStderr += chunk; + const lines = pendingStderr.split(/\r?\n/); + pendingStderr = lines.pop() ?? ""; + for (const line of lines) { + if (!line) continue; + options.onLog?.(line); + const ready = parseSessionReadyLine(line); + if (ready && !session) { + session = ready; + options.onSessionReady?.(ready); + } + } + }); + + child.stdin.end(command.stdin ?? ""); + + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode, signal) => { + clearTimeout(timeout); + if (pendingStderr) { + options.onLog?.(pendingStderr); + const ready = parseSessionReadyLine(pendingStderr); + if (ready && !session) session = ready; + } + resolve({ + command, + stdout, + stderr, + exitCode, + signal, + timedOut, + ...(session && { session }), + }); + }); + }); +} diff --git a/apps/debug-tui/src/process/session-ready.ts b/apps/debug-tui/src/process/session-ready.ts new file mode 100644 index 000000000..b9c545aeb --- /dev/null +++ b/apps/debug-tui/src/process/session-ready.ts @@ -0,0 +1,30 @@ +import type { PluginSessionInfo } from "@plannotator/shared/plugin-protocol"; + +const SESSION_READY_PREFIX = "PLANNOTATOR_SESSION_READY "; + +export function parseSessionReadyLine(line: string): PluginSessionInfo | null { + const index = line.indexOf(SESSION_READY_PREFIX); + if (index === -1) return null; + + try { + const value = JSON.parse(line.slice(index + SESSION_READY_PREFIX.length)); + if (!isSessionInfo(value)) return null; + return value; + } catch { + return null; + } +} + +function isSessionInfo(value: unknown): value is PluginSessionInfo { + if (!value || typeof value !== "object") return false; + const session = value as Partial; + return ( + (session.mode === "plan" || + session.mode === "review" || + session.mode === "annotate" || + session.mode === "archive") && + typeof session.url === "string" && + typeof session.port === "number" && + typeof session.isRemote === "boolean" + ); +} diff --git a/apps/debug-tui/src/scenarios/fixtures.ts b/apps/debug-tui/src/scenarios/fixtures.ts new file mode 100644 index 000000000..3c8ceae7f --- /dev/null +++ b/apps/debug-tui/src/scenarios/fixtures.ts @@ -0,0 +1,164 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface WorkspaceFixture { + root: string; + markdownPath: string; + htmlPath: string; + planPath: string; + archivePath: string; + interviewBundlePath: string; + factsBundlePath: string; + codexHome: string; + copilotHome: string; + geminiTranscriptPath: string; + geminiPlanFilename: string; + geminiSessionId: string; + cleanup(): Promise; +} + +const PLAN_MARKDOWN = `# Simulator plan + +This fixture proves that an agent protocol can create a daemon-backed Plannotator plan session. +`; + +export async function createWorkspaceFixture(label: string): Promise { + const root = await mkdtemp(join(tmpdir(), `plannotator-${label}-`)); + const markdownPath = join(root, "docs", "notes.md"); + const htmlPath = join(root, "docs", "page.html"); + const planPath = join(root, "plan.md"); + const archivePath = join(root, "plans"); + const codexHome = join(root, "codex-home"); + const copilotHome = join(root, "copilot-home"); + const geminiSessionId = "session-simulator"; + const geminiPlanFilename = "simulator-plan.md"; + const geminiTranscriptPath = join(root, "gemini", "chats", "session-1.json"); + + const goalsDir = join(root, "goals"); + const interviewBundlePath = join(goalsDir, "interview.json"); + const factsBundlePath = join(goalsDir, "facts.json"); + + await mkdir(join(root, "docs"), { recursive: true }); + await mkdir(archivePath, { recursive: true }); + await mkdir(goalsDir, { recursive: true }); + await writeFile(markdownPath, "# Notes\n\nAnnotate this markdown fixture.\n"); + await writeFile(htmlPath, "

    Fixture

    Annotate this HTML fixture.

    \n"); + await writeFile(planPath, PLAN_MARKDOWN); + await writeFile(join(archivePath, "approved-plan.md"), "# Approved fixture\n\nArchived plan.\n"); + await writeFile(interviewBundlePath, JSON.stringify({ + stage: "interview", + title: "Simulator goal setup", + goalSlug: "simulator-goal", + questions: [ + { id: "scope", prompt: "What is the scope of this goal?", answerMode: "text" }, + { id: "priority", prompt: "What priority is this?", answerMode: "single", options: [{ id: "high", label: "High" }, { id: "medium", label: "Medium" }, { id: "low", label: "Low" }], recommendedOptionIds: ["medium"] }, + ], + })); + await writeFile(factsBundlePath, JSON.stringify({ + stage: "facts", + title: "Simulator facts review", + goalSlug: "simulator-goal", + facts: [ + { id: "f1", text: "The daemon manages session lifecycle.", accepted: false, removed: false, automatedVerification: false }, + { id: "f2", text: "The CLI routes through the daemon.", accepted: true, removed: false, automatedVerification: true }, + ], + })); + + await createCodexFixture(codexHome); + await createCopilotFixture(copilotHome, root); + await createGeminiFixture({ + transcriptPath: geminiTranscriptPath, + sessionId: geminiSessionId, + planFilename: geminiPlanFilename, + }); + await createGitFixture(root); + + return { + root, + markdownPath, + htmlPath, + planPath, + archivePath, + interviewBundlePath, + factsBundlePath, + codexHome, + copilotHome, + geminiTranscriptPath, + geminiPlanFilename, + geminiSessionId, + cleanup: () => rm(root, { force: true, recursive: true }), + }; +} + +async function createCodexFixture(codexHome: string): Promise { + const threadId = "11111111-1111-4111-8111-111111111111"; + const rolloutDir = join(codexHome, ".codex", "sessions", "2026", "05", "17"); + await mkdir(rolloutDir, { recursive: true }); + const rolloutPath = join(rolloutDir, `rollout-2026-05-17T00-00-00-${threadId}.jsonl`); + const rows = [ + { + type: "event_msg", + payload: { + type: "item_completed", + turn_id: "turn-1", + item: { type: "Plan", text: PLAN_MARKDOWN }, + }, + }, + ]; + await writeFile(rolloutPath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`); +} + +async function createCopilotFixture(copilotHome: string, cwd: string): Promise { + const sessionId = "22222222-2222-4222-8222-222222222222"; + const sessionDir = join(copilotHome, "session-state", sessionId); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, "workspace.yaml"), `cwd: ${cwd}\n`); + await writeFile(join(sessionDir, "plan.md"), PLAN_MARKDOWN); +} + +async function createGeminiFixture({ + transcriptPath, + sessionId, + planFilename, +}: { + transcriptPath: string; + sessionId: string; + planFilename: string; +}): Promise { + const projectTempDir = dirname(dirname(transcriptPath)); + const planDir = join(projectTempDir, sessionId, "plans"); + await mkdir(planDir, { recursive: true }); + await mkdir(join(projectTempDir, "chats"), { recursive: true }); + await writeFile(transcriptPath, "{}\n"); + await writeFile(join(planDir, planFilename), PLAN_MARKDOWN); +} + +async function createGitFixture(cwd: string): Promise { + runGit(cwd, ["init"]); + runGit(cwd, ["config", "user.email", "simulator@example.com"]); + runGit(cwd, ["config", "user.name", "Plannotator Simulator"]); + runGit(cwd, ["add", "."]); + runGit(cwd, ["commit", "-m", "Initial simulator fixture"]); + await writeFile(join(cwd, "docs", "notes.md"), "# Notes\n\nChanged by simulator fixture.\n"); +} + +function runGit(cwd: string, args: string[]): void { + const result = spawnSync("git", args, { cwd, stdio: "ignore" }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed while preparing simulator fixture`); + } +} + +export function codexThreadId(): string { + return "11111111-1111-4111-8111-111111111111"; +} + +export function copilotSessionId(): string { + return "22222222-2222-4222-8222-222222222222"; +} + +export function simulatorPlan(): string { + return PLAN_MARKDOWN; +} diff --git a/apps/debug-tui/src/scenarios/index.test.ts b/apps/debug-tui/src/scenarios/index.test.ts new file mode 100644 index 000000000..59304d8d0 --- /dev/null +++ b/apps/debug-tui/src/scenarios/index.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vitest"; +import { scenarioDefinitions, getScenario } from "./index"; +import type { ScenarioId } from "./types"; + +const requiredScenarioIds: ScenarioId[] = [ + "claude-plan-hook", + "opencode-plan", + "opencode-review", + "opencode-annotate", + "opencode-archive", + "pi-plan", + "pi-review", + "pi-annotate", + "pi-archive", + "codex-plan-hook", + "copilot-plan-hook", + "gemini-plan-file-hook", + "cli-review", + "cli-annotate", + "cli-annotate-gate", + "cli-annotate-html", + "cli-annotate-url", + "cli-archive", + "cli-setup-goal-interview", + "cli-setup-goal-facts", +]; + +describe("agent simulator scenarios", () => { + test("covers every accepted fixture-backed scenario", () => { + expect(scenarioDefinitions.map((scenario) => scenario.id).sort()).toEqual( + [...requiredScenarioIds].sort(), + ); + }); + + test("every scenario declares protocol metadata and completion mode", () => { + for (const scenario of scenarioDefinitions) { + expect(scenario.title).not.toBe(""); + expect(["hook", "plugin", "cli"]).toContain(scenario.kind); + expect(["plan", "review", "annotate", "archive", "goal-setup"]).toContain(scenario.expectedSessionMode); + } + }); + + test("every scenario builds a runnable command with isolated local fixtures", async () => { + for (const scenario of scenarioDefinitions) { + const fixture = await scenario.buildFixture("/repo"); + try { + const command = scenario.buildCommand("/repo", fixture); + expect(command.command).toBe("/repo/bin/plannotator.js"); + expect(command.cwd).toBe(fixture.cwd); + expect(command.env?.PLANNOTATOR_SHARE).toBe("disabled"); + expect(command.env?.PLANNOTATOR_DISABLE_AUTO_INSTALL).toBe("1"); + expect(fixture.cleanup).toEqual(expect.any(Function)); + } finally { + await fixture.cleanup?.(); + } + } + }); + + test("builds plugin commands with realistic stdin", async () => { + const scenario = getScenario("opencode-plan"); + const fixture = await scenario.buildFixture("/repo"); + try { + const command = scenario.buildCommand("/repo", fixture); + expect(command.args).toEqual(["plugin", "plan", "--origin", "opencode"]); + expect(JSON.parse(command.stdin ?? "{}")).toMatchObject({ + origin: "opencode", + plan: expect.stringContaining("Simulator plan"), + }); + } finally { + await fixture.cleanup?.(); + } + }); +}); diff --git a/apps/debug-tui/src/scenarios/index.ts b/apps/debug-tui/src/scenarios/index.ts new file mode 100644 index 000000000..c0529eb05 --- /dev/null +++ b/apps/debug-tui/src/scenarios/index.ts @@ -0,0 +1,421 @@ +import { join } from "node:path"; +import type { PluginRequest } from "@plannotator/shared/plugin-protocol"; +import { + codexThreadId, + copilotSessionId, + createWorkspaceFixture, + simulatorPlan, +} from "./fixtures"; +import type { + CompletionMode, + ScenarioCommand, + ScenarioDefinition, + ScenarioFixture, + ScenarioId, +} from "./types"; + +const DEFAULT_TIMEOUT_MS = 10 * 60_000; + +function plannotatorCommand(repoRoot: string): string { + return join(repoRoot, "bin", "plannotator.js"); +} + +function commonEnv(fixture: ScenarioFixture): Record { + return { + PLANNOTATOR_BROWSER: browserNoopCommand(), + PLANNOTATOR_DISABLE_AUTO_INSTALL: "1", + PLANNOTATOR_SHARE: "disabled", + ...(fixture.env ?? {}), + }; +} + +function browserNoopCommand(): string { + return process.platform === "win32" ? "cmd" : "/usr/bin/true"; +} + +function command( + repoRoot: string, + fixture: ScenarioFixture, + args: string[], + stdin = fixture.stdin, +): ScenarioCommand { + return { + command: plannotatorCommand(repoRoot), + args, + stdin, + cwd: fixture.cwd, + env: commonEnv(fixture), + }; +} + +async function workspace(label: string): Promise { + const fixture = await createWorkspaceFixture(label); + return { + cwd: fixture.root, + cleanup: fixture.cleanup, + env: { + HOME: fixture.root, + COPILOT_HOME: fixture.copilotHome, + }, + }; +} + +async function pluginFixture(label: string, request: PluginRequest): Promise { + const fixture = await createWorkspaceFixture(label); + return { + cwd: fixture.root, + stdin: JSON.stringify({ ...request, cwd: fixture.root, timeoutMs: DEFAULT_TIMEOUT_MS }), + completion: modeForPluginAction(request.action), + cleanup: fixture.cleanup, + env: { + HOME: fixture.root, + COPILOT_HOME: fixture.copilotHome, + }, + }; +} + +function modeForPluginAction(action: PluginRequest["action"]): CompletionMode { + if (action === "annotate-last") return "annotate"; + return action; +} + +function pluginScenario({ + id, + title, + origin, + action, + request, +}: { + id: ScenarioId; + title: string; + origin: "opencode" | "pi"; + action: PluginRequest["action"]; + request: Record; +}): ScenarioDefinition { + const expectedSessionMode = modeForPluginAction(action); + return { + id, + title, + kind: "plugin", + agent: origin, + expectedSessionMode, + description: `Runs plannotator plugin ${action} using the ${origin} protocol.`, + buildFixture: (repoRoot) => + pluginFixture(id, { + ...request, + action, + origin, + sharingEnabled: false, + } as PluginRequest), + buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["plugin", action, "--origin", origin]), + }; +} + +export const scenarioDefinitions: ScenarioDefinition[] = [ + { + id: "claude-plan-hook", + title: "Claude Code plan hook", + kind: "hook", + agent: "claude-code", + expectedSessionMode: "plan", + description: "Feeds a Claude PermissionRequest ExitPlanMode event through stdin.", + async buildFixture() { + const fixture = await workspace("claude-plan"); + fixture.stdin = JSON.stringify({ + hook_event_name: "PermissionRequest", + tool_name: "ExitPlanMode", + tool_input: { plan: simulatorPlan() }, + permission_mode: "default", + }); + fixture.completion = "plan"; + return fixture; + }, + buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, []), + }, + pluginScenario({ + id: "opencode-plan", + title: "OpenCode plan", + origin: "opencode", + action: "plan", + request: { plan: simulatorPlan() }, + }), + pluginScenario({ + id: "opencode-review", + title: "OpenCode review", + origin: "opencode", + action: "review", + request: { args: "" }, + }), + pluginScenario({ + id: "opencode-annotate", + title: "OpenCode annotate", + origin: "opencode", + action: "annotate", + request: { markdown: "# Annotate\n\nOpenCode annotation fixture.", filePath: "opencode.md" }, + }), + pluginScenario({ + id: "opencode-archive", + title: "OpenCode archive", + origin: "opencode", + action: "archive", + request: {}, + }), + pluginScenario({ + id: "pi-plan", + title: "Pi plan", + origin: "pi", + action: "plan", + request: { plan: simulatorPlan() }, + }), + pluginScenario({ + id: "pi-review", + title: "Pi review", + origin: "pi", + action: "review", + request: { args: "" }, + }), + pluginScenario({ + id: "pi-annotate", + title: "Pi annotate", + origin: "pi", + action: "annotate", + request: { markdown: "# Annotate\n\nPi annotation fixture.", filePath: "pi.md" }, + }), + pluginScenario({ + id: "pi-archive", + title: "Pi archive", + origin: "pi", + action: "archive", + request: {}, + }), + { + id: "codex-plan-hook", + title: "Codex Stop hook plan", + kind: "hook", + agent: "codex", + expectedSessionMode: "plan", + description: "Feeds a Codex Stop hook and resolves the plan from a fixture rollout file.", + async buildFixture() { + const fixture = await createWorkspaceFixture("codex-plan"); + return { + cwd: fixture.root, + stdin: JSON.stringify({ + hook_event_name: "Stop", + turn_id: "turn-1", + stop_hook_active: true, + }), + completion: "plan", + cleanup: fixture.cleanup, + env: { + HOME: fixture.codexHome, + CODEX_THREAD_ID: codexThreadId(), + }, + }; + }, + buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, []), + }, + { + id: "copilot-plan-hook", + title: "Copilot plan hook", + kind: "hook", + agent: "copilot-cli", + expectedSessionMode: "plan", + description: "Runs the Copilot pre-tool hook against a local session-state fixture.", + async buildFixture() { + const fixture = await createWorkspaceFixture("copilot-plan"); + return { + cwd: fixture.root, + stdin: JSON.stringify({ + toolName: "exit_plan_mode", + toolArgs: "{}", + cwd: fixture.root, + timestamp: Date.now(), + sessionId: copilotSessionId(), + }), + completion: "plan", + cleanup: fixture.cleanup, + env: { + HOME: fixture.root, + COPILOT_HOME: fixture.copilotHome, + COPILOT_CLI: "1", + }, + }; + }, + buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["copilot-plan"]), + }, + { + id: "gemini-plan-file-hook", + title: "Gemini plan-file hook", + kind: "hook", + agent: "gemini-cli", + expectedSessionMode: "plan", + description: "Feeds a Gemini plan-file event and reads plan markdown from disk.", + async buildFixture() { + const fixture = await createWorkspaceFixture("gemini-plan"); + return { + cwd: fixture.root, + stdin: JSON.stringify({ + hook_event_name: "PermissionRequest", + transcript_path: fixture.geminiTranscriptPath, + session_id: fixture.geminiSessionId, + tool_input: { plan_filename: fixture.geminiPlanFilename }, + }), + completion: "plan", + cleanup: fixture.cleanup, + env: { + HOME: fixture.root, + GEMINI_CLI: "1", + }, + }; + }, + buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, []), + }, + { + id: "cli-review", + title: "Direct CLI review", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "review", + description: "Runs plannotator review against a local git diff fixture.", + buildFixture: async () => { + const fixture = await workspace("cli-review"); + fixture.completion = "review"; + return fixture; + }, + buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["review"]), + }, + { + id: "cli-annotate", + title: "Direct CLI annotate", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "annotate", + description: "Runs plannotator annotate against a markdown fixture.", + async buildFixture() { + const fixture = await createWorkspaceFixture("cli-annotate"); + return { + cwd: fixture.root, + completion: "annotate", + cleanup: fixture.cleanup, + env: { HOME: fixture.root }, + }; + }, + buildCommand: (repoRoot, fixture) => + command(repoRoot, fixture, ["annotate", "docs/notes.md", "--json"]), + }, + { + id: "cli-annotate-gate", + title: "CLI annotate with gate", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "annotate", + description: "Runs plannotator annotate --gate, adding an Approve button alongside annotations.", + async buildFixture() { + const fixture = await createWorkspaceFixture("cli-annotate-gate"); + return { + cwd: fixture.root, + completion: "annotate", + cleanup: fixture.cleanup, + env: { HOME: fixture.root }, + }; + }, + buildCommand: (repoRoot, fixture) => + command(repoRoot, fixture, ["annotate", "docs/notes.md", "--gate", "--json"]), + }, + { + id: "cli-annotate-html", + title: "CLI annotate --render-html", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "annotate", + description: "Runs plannotator annotate on an HTML file with --render-html (rendered as-is, not converted).", + async buildFixture() { + const fixture = await createWorkspaceFixture("cli-annotate-html"); + return { + cwd: fixture.root, + completion: "annotate", + cleanup: fixture.cleanup, + env: { HOME: fixture.root }, + }; + }, + buildCommand: (repoRoot, fixture) => + command(repoRoot, fixture, ["annotate", "docs/page.html", "--render-html", "--json"]), + }, + { + id: "cli-annotate-url", + title: "CLI annotate URL (--no-jina)", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "annotate", + description: "Runs plannotator annotate on a URL with Jina disabled (uses fetch+Turndown fallback).", + async buildFixture() { + const fixture = await createWorkspaceFixture("cli-annotate-url"); + return { + cwd: fixture.root, + completion: "annotate", + cleanup: fixture.cleanup, + env: { HOME: fixture.root }, + }; + }, + buildCommand: (repoRoot, fixture) => + command(repoRoot, fixture, ["annotate", "https://example.com", "--no-jina", "--json"]), + }, + { + id: "cli-archive", + title: "Direct CLI archive", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "archive", + description: "Runs plannotator archive and closes it through the session API.", + buildFixture: async () => { + const fixture = await workspace("cli-archive"); + fixture.completion = "archive"; + return fixture; + }, + buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["archive"]), + }, + { + id: "cli-setup-goal-interview", + title: "CLI setup-goal interview", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "goal-setup", + description: "Runs plannotator setup-goal interview with a question bundle fixture.", + async buildFixture() { + const fixture = await createWorkspaceFixture("cli-setup-goal-interview"); + return { + cwd: fixture.root, + completion: "goal-setup", + cleanup: fixture.cleanup, + env: { HOME: fixture.root }, + }; + }, + buildCommand: (repoRoot, fixture) => + command(repoRoot, fixture, ["setup-goal", "interview", "goals/interview.json", "--json"]), + }, + { + id: "cli-setup-goal-facts", + title: "CLI setup-goal facts", + kind: "cli", + agent: "direct-cli", + expectedSessionMode: "goal-setup", + description: "Runs plannotator setup-goal facts with a facts review bundle fixture.", + async buildFixture() { + const fixture = await createWorkspaceFixture("cli-setup-goal-facts"); + return { + cwd: fixture.root, + completion: "goal-setup", + cleanup: fixture.cleanup, + env: { HOME: fixture.root }, + }; + }, + buildCommand: (repoRoot, fixture) => + command(repoRoot, fixture, ["setup-goal", "facts", "goals/facts.json", "--json"]), + }, +]; + +export function getScenario(id: ScenarioId): ScenarioDefinition { + const scenario = scenarioDefinitions.find((item) => item.id === id); + if (!scenario) throw new Error(`Unknown simulator scenario: ${id}`); + return scenario; +} diff --git a/apps/debug-tui/src/scenarios/run-scenario.ts b/apps/debug-tui/src/scenarios/run-scenario.ts new file mode 100644 index 000000000..0cb2ebd47 --- /dev/null +++ b/apps/debug-tui/src/scenarios/run-scenario.ts @@ -0,0 +1,221 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { PluginSessionInfo } from "@plannotator/shared/plugin-protocol"; +import { completeSession, createSimulatorDaemonClient } from "../daemon/client"; +import { runPlannotatorCommand, type ProcessRunResult } from "../process/run-plannotator"; +import type { ScenarioDefinition, ScenarioFixture } from "./types"; + +export interface ScenarioRunLog { + at: string; + message: string; +} + +export interface ScenarioRunResult { + scenario: ScenarioDefinition; + fixture: ScenarioFixture; + process: ProcessRunResult; + session?: PluginSessionInfo; + daemonSessionsSeen: number; + logs: ScenarioRunLog[]; +} + +export interface ScenarioRunOptions { + repoRoot: string; + timeoutMs?: number; + fetch?: typeof fetch; + onLog?: (entry: ScenarioRunLog) => void; + completion?: "auto" | "manual"; + useSharedDaemon?: boolean; + stopDaemonOnFinish?: boolean; + daemonBaseUrl?: string; + daemonAuthToken?: string; +} + +export async function runScenario( + scenario: ScenarioDefinition, + options: ScenarioRunOptions, +): Promise { + const logs: ScenarioRunLog[] = []; + const fetchImpl = options.fetch ?? fetch; + const log = (message: string) => { + const entry = { at: new Date().toISOString(), message }; + logs.push(entry); + options.onLog?.(entry); + void publishDebugLog({ + baseUrl: options.daemonBaseUrl, + daemonAuthToken: options.daemonAuthToken, + fetch: fetchImpl, + scenarioId: scenario.id, + entry, + }); + }; + + await assertFrontendShellBuilt(options.repoRoot); + const fixture = await scenario.buildFixture(options.repoRoot); + const command = scenario.buildCommand(options.repoRoot, fixture); + if (options.useSharedDaemon && scenario.agent !== "codex") { + command.env = { ...(command.env ?? {}) }; + delete command.env.HOME; + } + let sessionFromStderr: PluginSessionInfo | undefined; + const shouldStopDaemon = options.stopDaemonOnFinish ?? !options.useSharedDaemon; + + try { + log(`starting ${scenario.id}`); + const processPromise = runPlannotatorCommand(command, { + timeoutMs: options.timeoutMs, + onLog: (line) => log(line), + onSessionReady: (session) => { + sessionFromStderr = session; + log(`session ready ${session.url}`); + }, + }); + + const session = await waitForScenarioSession({ + fixture, + expectedMode: scenario.expectedSessionMode, + sessionFromStderr: () => sessionFromStderr, + fetch: fetchImpl, + timeoutMs: options.timeoutMs ?? 120_000, + daemonBaseUrl: options.daemonBaseUrl, + daemonAuthToken: options.daemonAuthToken, + }); + if ((options.completion ?? "auto") === "auto") { + log(`completing ${session.mode} session ${session.url}`); + await completeSession(fetchImpl, session.url, scenario.expectedSessionMode); + } else { + log(`waiting for browser action at ${session.url}`); + } + + const process = await processPromise; + return { + scenario, + fixture, + process, + session, + daemonSessionsSeen: session.daemonSessionsSeen, + logs, + }; + } finally { + if (shouldStopDaemon) { + await shutdownDaemon(fixture, fetchImpl); + } + await fixture.cleanup?.(); + } +} + +async function assertFrontendShellBuilt(repoRoot: string): Promise { + try { + await access(join(repoRoot, "apps", "debug-frontend", "dist", "index.html")); + } catch { + throw new Error("Debug frontend shell is not built. Run `bun run build:debug-frontend` first."); + } +} + +async function waitForScenarioSession({ + fixture, + expectedMode, + sessionFromStderr, + fetch, + timeoutMs, + daemonBaseUrl, + daemonAuthToken, +}: { + fixture: ScenarioFixture; + expectedMode: string; + sessionFromStderr: () => PluginSessionInfo | undefined; + fetch: typeof globalThis.fetch; + timeoutMs: number; + daemonBaseUrl?: string; + daemonAuthToken?: string; +}): Promise { + const deadline = Date.now() + timeoutMs; + let daemonSessionsSeen = 0; + while (Date.now() < deadline) { + const ready = sessionFromStderr(); + const state = await readDaemonState(fixture); + const baseUrl = daemonBaseUrl ?? state?.baseUrl ?? (ready ? new URL(ready.url).origin : undefined); + const authToken = daemonAuthToken ?? state?.authToken; + if (baseUrl) { + const client = createSimulatorDaemonClient(baseUrl, fetch, { authToken }); + try { + const sessions = await client.listSessions(); + daemonSessionsSeen = Math.max(daemonSessionsSeen, sessions.length); + const session = sessions.find( + (item) => item.mode === expectedMode && (!ready || item.url === ready.url), + ); + if (session) { + const sessionUrl = session.url; + const url = new URL(sessionUrl); + return { + mode: expectedMode as PluginSessionInfo["mode"], + url: sessionUrl, + port: Number(url.port), + isRemote: ready?.isRemote ?? state?.isRemote === true, + daemonSessionsSeen, + }; + } + } catch { + // The daemon state can appear before the server is ready. Keep polling. + } + } + + await sleep(100); + } + throw new Error(`Timed out waiting for ${expectedMode} session.`); +} + +async function publishDebugLog({ + baseUrl, + daemonAuthToken, + fetch, + scenarioId, + entry, +}: { + baseUrl?: string; + daemonAuthToken?: string; + fetch: typeof globalThis.fetch; + scenarioId: string; + entry: ScenarioRunLog; +}): Promise { + if (!baseUrl) return; + try { + await createSimulatorDaemonClient(baseUrl, fetch, { authToken: daemonAuthToken }).postDebugLog({ + at: entry.at, + source: "agent-simulator", + scenarioId, + message: entry.message, + level: "info", + }); + } catch { + // Debug forwarding must never change scenario behavior. + } +} + +async function readDaemonState(fixture: ScenarioFixture): Promise<{ baseUrl?: string; isRemote?: boolean; authToken?: string } | null> { + const home = fixture.env?.HOME ?? process.env.HOME; + if (!home) return null; + try { + return JSON.parse(await readFile(join(home, ".plannotator", "daemon.json"), "utf8")) as { + baseUrl?: string; + isRemote?: boolean; + authToken?: string; + }; + } catch { + return null; + } +} + +async function shutdownDaemon(fixture: ScenarioFixture, fetchImpl: typeof fetch): Promise { + const state = await readDaemonState(fixture); + if (!state?.baseUrl) return; + try { + await createSimulatorDaemonClient(state.baseUrl, fetchImpl, { authToken: state.authToken }).shutdown(); + } catch { + // Best-effort test cleanup; failed scenario output still contains process logs. + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/debug-tui/src/scenarios/types.ts b/apps/debug-tui/src/scenarios/types.ts new file mode 100644 index 000000000..5431140ac --- /dev/null +++ b/apps/debug-tui/src/scenarios/types.ts @@ -0,0 +1,59 @@ +import type { PluginRequest } from "@plannotator/shared/plugin-protocol"; + +export type ScenarioId = + | "claude-plan-hook" + | "opencode-plan" + | "opencode-review" + | "opencode-annotate" + | "opencode-archive" + | "pi-plan" + | "pi-review" + | "pi-annotate" + | "pi-archive" + | "codex-plan-hook" + | "copilot-plan-hook" + | "gemini-plan-file-hook" + | "cli-review" + | "cli-annotate" + | "cli-annotate-gate" + | "cli-annotate-html" + | "cli-annotate-url" + | "cli-archive" + | "cli-setup-goal-interview" + | "cli-setup-goal-facts"; + +export type ScenarioKind = "hook" | "plugin" | "cli"; +export type CompletionMode = "plan" | "review" | "annotate" | "archive" | "goal-setup"; + +export interface ScenarioFixture { + cwd: string; + stdin?: string; + env?: Record; + cleanup?: () => Promise | void; + completion?: CompletionMode; +} + +export interface ScenarioCommand { + command: string; + args: string[]; + stdin?: string; + cwd: string; + env?: Record; +} + +export interface ScenarioDefinition { + id: ScenarioId; + title: string; + kind: ScenarioKind; + agent: "claude-code" | "opencode" | "pi" | "codex" | "copilot-cli" | "gemini-cli" | "direct-cli"; + description: string; + expectedSessionMode: CompletionMode; + buildFixture(repoRoot: string): Promise; + buildCommand(repoRoot: string, fixture: ScenarioFixture): ScenarioCommand; +} + +export interface PluginScenarioOptions { + origin: "opencode" | "pi"; + action: PluginRequest["action"]; + request: PluginRequest; +} diff --git a/apps/debug-tui/tsconfig.json b/apps/debug-tui/tsconfig.json new file mode 100644 index 000000000..50529f270 --- /dev/null +++ b/apps/debug-tui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", + "strict": true, + "skipLibCheck": true, + "moduleDetection": "force", + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src"] +} diff --git a/apps/debug-tui/vitest.config.ts b/apps/debug-tui/vitest.config.ts new file mode 100644 index 000000000..840e944d9 --- /dev/null +++ b/apps/debug-tui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, +}); diff --git a/apps/hook/package.json b/apps/hook/package.json index cb02f5a38..f6b955f78 100644 --- a/apps/hook/package.json +++ b/apps/hook/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html", + "build": "bun run --cwd ../debug-frontend build && vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html", "serve": "bun run server/index.ts" }, "dependencies": { diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index eeccf300f..c830ec65d 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -29,7 +29,6 @@ export function formatTopLevelHelp(): string { " plannotator annotate [--no-jina] [--gate] [--json] [--hook]", " plannotator last", " plannotator archive", - " plannotator setup-goal [--json]", " plannotator sessions", " plannotator daemon start|status|stop", " plannotator improve-context", diff --git a/apps/hook/server/daemon-shell-html.ts b/apps/hook/server/daemon-shell-html.ts new file mode 100644 index 000000000..0c0c4530f --- /dev/null +++ b/apps/hook/server/daemon-shell-html.ts @@ -0,0 +1,7 @@ +// TODO: Replace debug-frontend with production frontend (layer 5 in stack). +// Keep the daemon shell import separate from legacy mode HTML so direct +// non-daemon commands do not require apps/debug-frontend/dist unless the daemon starts. +// @ts-ignore - Bun import attribute for text +import shellHtml from "../../debug-frontend/dist/index.html" with { type: "text" }; + +export const daemonShellHtmlContent = shellHtml as unknown as string; diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bf380be2..7d451b269 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -63,10 +63,6 @@ import { } from "@plannotator/server/annotate"; import { loadConfig, resolveUseJina } from "@plannotator/shared/config"; import { parseReviewArgs } from "@plannotator/shared/review-args"; -import { - normalizeGoalSetupBundle, - type GoalSetupStage, -} from "@plannotator/shared/goal-setup"; import { statSync, existsSync, rmSync } from "fs"; import { tmpdir } from "os"; import { @@ -81,6 +77,7 @@ import { cleanupDaemonState, discoverDaemon, waitForDaemonShutdown } from "@plan import { startDaemonRuntime } from "@plannotator/server/daemon/runtime"; import { createDaemonSessionFactory } from "@plannotator/server/daemon/session-factory"; import { getDaemonStartCommand } from "@plannotator/server/daemon/start-command"; +import { createDaemonBrowserAuthUrl } from "@plannotator/server/daemon/state"; import { formatRemoteShareNotice } from "@plannotator/server/share-url"; import { hostnameOrFallback } from "@plannotator/shared/project"; import { readImprovementHook } from "@plannotator/shared/improvement-hooks"; @@ -123,7 +120,9 @@ import path from "path"; let planHtmlContentPromise: Promise | undefined; let reviewHtmlContentPromise: Promise | undefined; +let daemonShellHtmlContentPromise: Promise | undefined; let htmlAssetsPromise: Promise | undefined; +let daemonShellHtmlPromise: Promise | undefined; function getHtmlAssets() { htmlAssetsPromise ??= import("./html-assets"); @@ -140,12 +139,10 @@ function getReviewHtmlContent(): Promise { return reviewHtmlContentPromise; } -async function loadGoalSetupBundle(stage: GoalSetupStage, bundlePath: string) { - const raw = - bundlePath === "-" - ? await Bun.stdin.text() - : await Bun.file(path.resolve(bundlePath)).text(); - return normalizeGoalSetupBundle(JSON.parse(raw), stage); +function getDaemonShellHtmlContent(): Promise { + daemonShellHtmlPromise ??= import("./daemon-shell-html"); + daemonShellHtmlContentPromise ??= daemonShellHtmlPromise.then((mod) => mod.daemonShellHtmlContent); + return daemonShellHtmlContentPromise; } // Check for subcommand @@ -286,7 +283,11 @@ async function runDaemonCommand(): Promise { console.log(JSON.stringify({ ok: false, code: daemon.code, message: daemon.message })); process.exit(1); } - console.log(JSON.stringify({ ok: true, status: daemon.status })); + console.log(JSON.stringify({ + ok: true, + status: daemon.status, + browserUrl: createDaemonBrowserAuthUrl(daemon.state), + })); process.exit(0); } @@ -320,7 +321,12 @@ async function runDaemonCommand(): Promise { if (command === "start") { const existing = await discoverDaemon(); if (existing.ok) { - console.log(JSON.stringify({ ok: true, alreadyRunning: true, status: existing.status })); + console.log(JSON.stringify({ + ok: true, + alreadyRunning: true, + status: existing.status, + browserUrl: createDaemonBrowserAuthUrl(existing.state), + })); process.exit(0); } if (existing.state && (existing.code === "incompatible" || existing.code === "unhealthy")) { @@ -331,27 +337,59 @@ async function runDaemonCommand(): Promise { } if (!foreground) { + const startLogPath = path.join(tmpdir(), `plannotator-daemon-start-${process.pid}-${Date.now()}.log`); const child = Bun.spawn(getDaemonStartCommand(process.argv, process.execPath, launcherCwd), { cwd: getInvocationCwd(), stdin: "ignore", stdout: "ignore", - stderr: "ignore", + stderr: Bun.file(startLogPath), + detached: true, }); child.unref(); + let startExit: { exitCode?: number; error?: unknown } | undefined; + void child.exited + .then((exitCode) => { + startExit = { exitCode }; + }) + .catch((error) => { + startExit = { error }; + }); for (let attempt = 0; attempt < 30; attempt++) { await Bun.sleep(100); const daemon = await discoverDaemon(); if (daemon.ok) { - console.log(JSON.stringify({ ok: true, started: true, status: daemon.status })); + try { rmSync(startLogPath, { force: true }); } catch {} + console.log(JSON.stringify({ + ok: true, + started: true, + status: daemon.status, + browserUrl: createDaemonBrowserAuthUrl(daemon.state), + })); process.exit(0); } + if (startExit) { + const log = await readDaemonStartLog(startLogPath); + const detail = startExit.error instanceof Error + ? startExit.error.message + : `exited with code ${startExit.exitCode ?? "unknown"}`; + console.log(JSON.stringify({ + ok: false, + code: "daemon-start-failed", + message: `Plannotator daemon start ${detail}.${log ? `\n${log}` : ""}`, + })); + process.exit(1); + } } + if (!startExit) { + await stopDaemonStartChild(child); + } + const log = await readDaemonStartLog(startLogPath); console.log(JSON.stringify({ ok: false, code: "daemon-start-failed", - message: "Timed out waiting for the Plannotator daemon to start.", + message: `Timed out waiting for the Plannotator daemon to start.${log ? `\n${log}` : ""}`, })); process.exit(1); } @@ -359,6 +397,7 @@ async function runDaemonCommand(): Promise { let runtime: Awaited>; try { runtime = await startDaemonRuntime({ + shellHtmlContent: await getDaemonShellHtmlContent(), createSession: createDaemonSessionFactory({ planHtmlContent: await getPlanHtmlContent(), reviewHtmlContent: await getReviewHtmlContent(), @@ -371,15 +410,17 @@ async function runDaemonCommand(): Promise { }, }); } catch (err) { - console.log(JSON.stringify({ + const payload = { ok: false, code: "daemon-start-failed", message: err instanceof Error ? err.message : "Failed to start Plannotator daemon.", - })); + }; + console.error(JSON.stringify(payload)); + console.log(JSON.stringify(payload)); process.exit(1); } - console.log(JSON.stringify({ ok: true, started: true, status: { + console.log(JSON.stringify({ ok: true, started: true, browserUrl: createDaemonBrowserAuthUrl(runtime.state), status: { pid: runtime.state.pid, endpoint: { hostname: runtime.state.hostname, @@ -580,8 +621,12 @@ async function ensureDaemonClient(options: { pluginError?: boolean } = {}) { fail("daemon-start-failed", "Timed out waiting for the Plannotator daemon to start."); } -function registerDaemonSessionInterruptCleanup(cancelSession: () => Promise): () => void { +function registerDaemonSessionInterruptCleanup( + cancelSession: () => Promise, + options: { cancelOnSigterm?: boolean } = {}, +): () => void { let cancelling = false; + const cancelOnSigterm = options.cancelOnSigterm ?? true; const handleSignal = (exitCode: number) => { if (cancelling) return; cancelling = true; @@ -590,10 +635,10 @@ function registerDaemonSessionInterruptCleanup(cancelSession: () => Promise handleSignal(130); const onSigterm = () => handleSignal(143); process.once("SIGINT", onSigint); - process.once("SIGTERM", onSigterm); + if (cancelOnSigterm) process.once("SIGTERM", onSigterm); return () => { process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); + if (cancelOnSigterm) process.off("SIGTERM", onSigterm); }; } @@ -631,10 +676,13 @@ async function runDaemonSessionRequest(request: PluginRequest, options: { plugin fail(created.error.code, created.error.message); } createdSessionId = created.session.id; - unregisterInterruptCleanup = registerDaemonSessionInterruptCleanup(cancelCreatedSession); + unregisterInterruptCleanup = registerDaemonSessionInterruptCleanup(cancelCreatedSession, { + cancelOnSigterm: !options.pluginError, + }); const sessionUrl = new URL(created.session.url); const sessionPort = Number(sessionUrl.port); + const browserSessionUrl = createDaemonBrowserAuthUrl(daemon.state, sessionUrl.pathname); const session: PluginSessionInfo = { mode: created.session.mode, url: created.session.url, @@ -652,11 +700,11 @@ async function runDaemonSessionRequest(request: PluginRequest, options: { plugin await withProcessCwd(request.cwd, async () => { if (request.action === "review") { - await handleReviewServerReady(created.session.url, daemon.state.isRemote, sessionPort); + await handleReviewServerReady(browserSessionUrl, daemon.state.isRemote, sessionPort); } else if (request.action === "annotate" || request.action === "annotate-last") { - await handleAnnotateServerReady(created.session.url, daemon.state.isRemote, sessionPort); + await handleAnnotateServerReady(browserSessionUrl, daemon.state.isRemote, sessionPort); } else { - await handleServerReady(created.session.url, daemon.state.isRemote, sessionPort); + await handleServerReady(browserSessionUrl, daemon.state.isRemote, sessionPort); } }); @@ -817,7 +865,7 @@ if (args[0] === "sessions") { console.error(`Session #${n} not found. ${sessions.length} active session(s).`); process.exit(1); } - await openBrowser(session.url, { isRemote: daemon.status.endpoint.isRemote }); + await openBrowser(createDaemonBrowserAuthUrl(daemon.state, new URL(session.url).pathname), { isRemote: daemon.status.endpoint.isRemote }); console.error(`Opened ${session.mode} session in browser: ${session.url}`); process.exit(0); } @@ -1005,55 +1053,6 @@ if (args[0] === "sessions") { }); process.exit(0); -} else if (args[0] === "setup-goal") { - // ============================================ - // GOAL SETUP MODE - // ============================================ - - const stage = args[1] as GoalSetupStage | undefined; - const bundlePath = args[2]; - - if ((stage !== "interview" && stage !== "facts") || !bundlePath) { - console.error( - "Usage: plannotator setup-goal [--json]", - ); - process.exit(1); - } - - let bundle: Awaited>; - try { - bundle = await loadGoalSetupBundle(stage, bundlePath); - } catch (err) { - console.error( - `Failed to load goal setup bundle: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); - } - - const outcome = await runDaemonSessionRequest({ - action: "goal-setup", - origin: detectedOrigin, - cwd: getInvocationCwd(), - bundle, - stage, - goalSlug: bundle.goalSlug, - }); - - if (outcome?.result) { - const result = outcome.result as { result?: unknown; exit?: boolean }; - if (result.exit) { - console.log(JSON.stringify({ decision: "dismissed", stage })); - } else if (result.result) { - const output = { - decision: "submitted", - stage, - result: result.result, - }; - console.log(jsonFlag ? JSON.stringify(output) : JSON.stringify(output, null, 2)); - } - } - process.exit(0); - } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE diff --git a/apps/opencode-plugin/binary-client.test.ts b/apps/opencode-plugin/binary-client.test.ts index 0a6c14ad5..ea66cbeec 100644 --- a/apps/opencode-plugin/binary-client.test.ts +++ b/apps/opencode-plugin/binary-client.test.ts @@ -135,7 +135,13 @@ describe("OpenCode binary client", () => { }); test("uses the local source shim before auto-installing", () => { - const existing = new Set(["/repo/plannotator/bin/plannotator.js"]); + const existing = new Set([ + "/repo/plannotator/bin/plannotator.js", + "/repo/plannotator/apps/hook/server/index.ts", + "/repo/plannotator/apps/hook/dist/index.html", + "/repo/plannotator/apps/hook/dist/review.html", + "/repo/plannotator/apps/debug-frontend/dist/index.html", + ]); const commands: Array<[string, string[]]> = []; const run: CommandRunner = (command, args) => { commands.push([command, args]); diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 6abdff2c4..943c3dc67 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -41,6 +41,18 @@ export function getStartupErrorMessage(err: unknown): string { return err instanceof Error ? err.message : "Unknown error"; } +export class PlannotatorBinaryStartupError extends Error { + readonly code: string; + readonly checked: string[]; + + constructor(result: { code: string; message: string; checked?: string[] }) { + super(result.message); + this.name = "PlannotatorBinaryStartupError"; + this.code = result.code; + this.checked = result.checked ?? []; + } +} + export function shouldUseLocalPrCheckout(options: { useLocal?: boolean }): boolean { return options.useLocal !== false; } @@ -51,19 +63,19 @@ export function normalizeAnnotationMarkdownForBinary(markdown: string | undefine const SOURCE_ROOT = findPlannotatorSourceRoot(dirname(fileURLToPath(import.meta.url))); -function sharingRequest(ctx: ExtensionContext) { +function sharingRequest(ctx: ExtensionContext, env: NodeJS.ProcessEnv = process.env) { return { cwd: ctx.cwd, - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + sharingEnabled: env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: env.PLANNOTATOR_PASTE_URL || undefined, }; } function getBinaryPath(requiredFeatures?: readonly PluginFeature[]): string { const binary = ensurePlannotatorBinary({ requiredFeatures, sourceRoot: SOURCE_ROOT }); if (!binary.ok) { - throw new Error(binary.message); + throw new PlannotatorBinaryStartupError(binary); } return binary.path; } diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 5a154fa6b..735eb23c4 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -68,6 +68,7 @@ export interface PlannotatorPlanReviewStartResult { } export interface PlannotatorReviewResultEvent { + status?: "completed"; reviewId: string; approved: boolean; feedback?: string; @@ -76,6 +77,12 @@ export interface PlannotatorReviewResultEvent { permissionMode?: string; } +export interface PlannotatorReviewErrorEvent { + status: "error"; + reviewId: string; + error: string; +} + export interface PlannotatorReviewStatusPayload { reviewId: string; } @@ -255,10 +262,16 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); }); void session.waitForDecision().catch((err) => { - setStoredReviewStatus(session.reviewId, { + const errorResult = { status: "error", + reviewId: session.reviewId, error: getStartupErrorMessage(err), + } satisfies PlannotatorReviewErrorEvent; + setStoredReviewStatus(session.reviewId, { + status: "error", + error: errorResult.error, }); + pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, errorResult); }); request.respond({ status: "handled", diff --git a/bun.lock b/bun.lock index 7026b04f9..644d6a809 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,51 @@ "typescript": "~5.8.2", }, }, + "apps/debug-frontend": { + "name": "@plannotator/debug-frontend", + "version": "0.0.1", + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@tanstack/react-router": "^1.141.0", + "immer": "^10.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "zustand": "^5.0.13", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-plugin": "^1.141.0", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "@vitest/browser-playwright": "^4.0.16", + "oxfmt": "^0.17.0", + "oxlint": "^1.31.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + "vitest": "^4.0.16", + }, + }, + "apps/debug-tui": { + "name": "@plannotator/debug-tui", + "version": "0.0.1", + "dependencies": { + "@opentui/core": "0.2.6", + "@opentui/react": "0.2.6", + "@plannotator/shared": "workspace:*", + "react": "^19.2.3", + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "typescript": "~5.8.2", + "vitest": "^4.0.16", + }, + }, "apps/hook": { "name": "@plannotator/hooks", "version": "0.0.1", @@ -193,7 +238,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.17", + "version": "0.19.18", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", @@ -225,6 +270,7 @@ "@viz-js/viz": "^3.25.0", "diff": "^8.0.3", "highlight.js": "^11.11.1", + "lucide-react": "^1.14.0", "marked": "^17.0.6", "mermaid": "^11.12.2", "overlayscrollbars": "^2.11.0", @@ -236,7 +282,6 @@ }, "devDependencies": { "@types/bun": "^1.2.0", - "@types/dompurify": "^3.0.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "typescript": "~5.8.2", @@ -382,7 +427,7 @@ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], @@ -394,6 +439,10 @@ "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], @@ -404,7 +453,9 @@ "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], - "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], @@ -648,6 +699,22 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.5", "", {}, "sha512-ckKedqONnigSejAm/UVlBuQP0U1Ozn9uC54zLxz/EqQZPWE8y7V+8PT048zC7q6gqI+puj2jns65/+enJSkTEQ=="], + "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="], + + "@opentui/react": ["@opentui/react@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-pZfgKVZ0G46jS/KyHvfUB/jDGDuuhfLgDG8ue0qQ6/2jm0e2JqNoLjdkHsN5MzVJhjnTyWLsjVYc3nSAamC12Q=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ=="], @@ -672,6 +739,60 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g=="], + "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.17.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OMv0tOb+xiwSZKjYbM6TwMSP5QwFJlBGQmEsk98QJ30sHhdyC//0UvGKuR0KZuzZW4E0+k0rHDmos1Z5DmBEkA=="], + + "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.17.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-trzidyzryKIdL/cLCYU9IwprgJegVBUrz1rqzOMe5is+qdgH/RxTCvhYUNFzxRHpil3g4QUYd2Ja831tc5Nehg=="], + + "@oxfmt/linux-arm64-gnu": ["@oxfmt/linux-arm64-gnu@0.17.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-KlwzidgvHznbUaaglZT1goTS30osTV553pfbKve9B1PyTDkluNDfm/polOaf3SVLN7wL/NNLFZRMupvJ1eJXAw=="], + + "@oxfmt/linux-arm64-musl": ["@oxfmt/linux-arm64-musl@0.17.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tbYJTocF4BNLaQQbc/xrBWTNgiU6zmYeF4NvRDxuuQjDOnmUZPn0EED3PZBRJyg4/YllhplHDo8x+gfcb9G3A=="], + + "@oxfmt/linux-x64-gnu": ["@oxfmt/linux-x64-gnu@0.17.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pEmv7zJIw2HpnA4Tn1xrfJNGi2wOH2+usT14Pkvf/c5DdB+pOir6k/5jzfe70+V3nEtmtV9Lm+spndN/y6+X7A=="], + + "@oxfmt/linux-x64-musl": ["@oxfmt/linux-x64-musl@0.17.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+DrFSCZWyFdtEAWR5xIBTV8GX0RA9iB+y7ZlJPRAXrNG8TdBY9vc7/MIGolIgrkMPK4mGMn07YG/qEyPY+iKaw=="], + + "@oxfmt/win32-arm64": ["@oxfmt/win32-arm64@0.17.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FoUZRR7mVpTYIaY/qz2BYwzqMnL+HsUxmMWAIy6nl29UEkDgxNygULJ4rIGY4/Axne41fhtldLrSGBOpwNm3jA=="], + + "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.17.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fBIcUpHmCwf3leWlo0cYwLb9Pd2mzxQlZYJX9dD9nylPvsxOnsy9fmsaflpj34O0JbQJN3Y0SRkoaCcHHlxFww=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.63.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.63.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.63.0", "", { "os": "none", "cpu": "arm64" }, "sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.63.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA=="], + "@pierre/diffs": ["@pierre/diffs@1.1.20", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-lLi+3sLCm3QDd5/aLO9pw+WbF6UzhrkWm2oTZ5WZJTGemOyUNRJ4DDhcEKmVusu4C4bXx9Nssh6fF+wQcapb5w=="], "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], @@ -680,6 +801,10 @@ "@plannotator/ai": ["@plannotator/ai@workspace:packages/ai"], + "@plannotator/debug-frontend": ["@plannotator/debug-frontend@workspace:apps/debug-frontend"], + + "@plannotator/debug-tui": ["@plannotator/debug-tui@workspace:apps/debug-tui"], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], @@ -706,6 +831,8 @@ "@plannotator/web-highlighter": ["@plannotator/web-highlighter@0.8.1", "", {}, "sha512-FlteNOwRj9iNSY/AhFMtqOnVS4FvsACvTw6IiOM1y8iDyhiU/WeZOgjURENvIY+wuUaiS9DDFmg0PrHMyuMR1Q=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -974,6 +1101,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -1004,10 +1133,28 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], + + "@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-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + "@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], + + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.42", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^3.24.2" } }, "sha512-2qBWC0t78r6b3vI+AbnvCZcFAvbYBDlLuWZrTjQbcjUmwG3qyeQp983tJyDuj9wb5//adG1tgAGXZkJ3aDwdBg=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.35", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-generator": "1.166.42", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^3.0.0", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.169.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-UAScU5VAzLYVY4FML/Cbc5S5TucT4I8Ata05yozGOe4ZfepTKRffA5xWLtD2N+ov5svdv0KTX/kqlZnYPe28mA=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.8", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-xyiLWEKjfBAVhauDSSjXxyf7s8elU6SM+V050sbkofvGmIIvkwPFtDsX7Gvwh14kBd6iCwAT+RiPvXTxAptY0Q=="], + + "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], + "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], @@ -1034,6 +1181,8 @@ "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -1098,6 +1247,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -1148,6 +1299,24 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "@vitest/browser": ["@vitest/browser@4.1.5", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.5" } }, "sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.5", "", { "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.5" } }, "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA=="], + + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], + + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], + + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], + "@viz-js/viz": ["@viz-js/viz@3.25.0", "", {}, "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw=="], "@vscode/vsce": ["@vscode/vsce@3.7.1", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^11.0.0", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^2.3.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g=="], @@ -1194,6 +1363,8 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], @@ -1210,6 +1381,8 @@ "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], @@ -1224,6 +1397,8 @@ "azure-devops-node-api": ["azure-devops-node-api@12.5.0", "", { "dependencies": { "tunnel": "0.0.6", "typed-rest-client": "^1.8.4" } }, "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -1238,6 +1413,8 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -1268,6 +1445,8 @@ "bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1284,6 +1463,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1302,7 +1483,7 @@ "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], @@ -1348,7 +1529,7 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -1538,7 +1719,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], @@ -1590,6 +1771,8 @@ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], @@ -1756,6 +1939,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], @@ -1780,6 +1965,8 @@ "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -1802,6 +1989,8 @@ "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "isbot": ["isbot@5.1.40", "", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istextorbinary": ["istextorbinary@9.5.0", "", { "dependencies": { "binaryextensions": "^6.11.0", "editions": "^6.21.0", "textextensions": "^6.11.0" } }, "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw=="], @@ -1908,6 +2097,8 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], @@ -2114,6 +2305,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -2134,6 +2327,10 @@ "overlayscrollbars-react": ["overlayscrollbars-react@0.5.6", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "react": ">=16.8.0" } }, "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw=="], + "oxfmt": ["oxfmt@0.17.0", "", { "optionalDependencies": { "@oxfmt/darwin-arm64": "0.17.0", "@oxfmt/darwin-x64": "0.17.0", "@oxfmt/linux-arm64-gnu": "0.17.0", "@oxfmt/linux-arm64-musl": "0.17.0", "@oxfmt/linux-x64-gnu": "0.17.0", "@oxfmt/linux-x64-musl": "0.17.0", "@oxfmt/win32-arm64": "0.17.0", "@oxfmt/win32-x64": "0.17.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-12Rmq2ub61rUZ3Pqnsvmo99rRQ6hQJwQsjnFnbvXYLMrlIsWT6SFVsrjAkBBrkXXSHv8ePIpKQ0nZph5KDrOqw=="], + + "oxlint": ["oxlint@1.63.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.63.0", "@oxlint/binding-android-arm64": "1.63.0", "@oxlint/binding-darwin-arm64": "1.63.0", "@oxlint/binding-darwin-x64": "1.63.0", "@oxlint/binding-freebsd-x64": "1.63.0", "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", "@oxlint/binding-linux-arm-musleabihf": "1.63.0", "@oxlint/binding-linux-arm64-gnu": "1.63.0", "@oxlint/binding-linux-arm64-musl": "1.63.0", "@oxlint/binding-linux-ppc64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-musl": "1.63.0", "@oxlint/binding-linux-s390x-gnu": "1.63.0", "@oxlint/binding-linux-x64-gnu": "1.63.0", "@oxlint/binding-linux-x64-musl": "1.63.0", "@oxlint/binding-openharmony-arm64": "1.63.0", "@oxlint/binding-win32-arm64-msvc": "1.63.0", "@oxlint/binding-win32-ia32-msvc": "1.63.0", "@oxlint/binding-win32-x64-msvc": "1.63.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg=="], + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -2200,8 +2397,14 @@ "plannotator-webview": ["plannotator-webview@workspace:apps/vscode-extension"], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], @@ -2210,6 +2413,8 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -2248,8 +2453,12 @@ "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "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=="], @@ -2264,7 +2473,7 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], @@ -2354,6 +2563,10 @@ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], + + "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -2364,6 +2577,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -2374,6 +2589,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], @@ -2382,6 +2599,8 @@ "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sitemap": ["sitemap@8.0.2", "", { "dependencies": { "@types/node": "^17.0.5", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/cli.js" } }, "sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ=="], @@ -2416,10 +2635,14 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], "stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="], @@ -2478,10 +2701,14 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -2490,6 +2717,8 @@ "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -2568,6 +2797,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -2578,6 +2809,8 @@ "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=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], @@ -2600,6 +2833,8 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -2616,6 +2851,10 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -2624,6 +2863,8 @@ "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], "workerd": ["workerd@1.20250718.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250718.0", "@cloudflare/workerd-darwin-arm64": "1.20250718.0", "@cloudflare/workerd-linux-64": "1.20250718.0", "@cloudflare/workerd-linux-arm64": "1.20250718.0", "@cloudflare/workerd-windows-64": "1.20250718.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg=="], @@ -2636,7 +2877,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -2666,6 +2907,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], @@ -2674,8 +2917,12 @@ "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], + "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@astrojs/mdx/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@astrojs/sitemap/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2698,12 +2945,30 @@ "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "@babel/core/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/generator/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@babel/helpers/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@babel/parser/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@babel/plugin-transform-react-jsx-self/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-transform-react-jsx-source/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/template/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@babel/traverse/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], "@chevrotain/gast/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], @@ -2720,14 +2985,18 @@ "@earendil-works/pi-tui/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - "@google/genai/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.25", "", {}, "sha512-mWUX489ArEF2ICg3iZsx2VQaGS3Z2j/dwAJDacao9t7dGDzjOIaacPw2weZ10zld7XmT9V9C0PM/A5lDZ52J+w=="], - "@mistralai/mistralai/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.25", "", {}, "sha512-mWUX489ArEF2ICg3iZsx2VQaGS3Z2j/dwAJDacao9t7dGDzjOIaacPw2weZ10zld7XmT9V9C0PM/A5lDZ52J+w=="], + "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@plannotator/debug-frontend/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + + "@plannotator/debug-tui/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@plannotator/hooks/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], "@plannotator/portal/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], @@ -2750,6 +3019,14 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@textlint/linter-formatter/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], @@ -2758,6 +3035,14 @@ "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@types/babel__core/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@types/babel__generator/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@types/babel__template/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@types/babel__traverse/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@types/sax/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], "@vscode/vsce/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -2774,10 +3059,14 @@ "astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "astro/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "astro/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "astro/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -2824,6 +3113,8 @@ "glob/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -2834,8 +3125,6 @@ "magicast/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - "magicast/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mermaid/dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], @@ -2850,6 +3139,8 @@ "miniflare/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -2866,12 +3157,18 @@ "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "protobufjs/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], @@ -2896,6 +3193,8 @@ "table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -2938,6 +3237,10 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@plannotator/debug-frontend/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@plannotator/debug-tui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@plannotator/hooks/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@plannotator/portal/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -3054,6 +3357,8 @@ "table/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], diff --git a/package.json b/package.json index 3fb1540fe..4115ce65f 100644 --- a/package.json +++ b/package.json @@ -22,18 +22,24 @@ "dev:portal": "bun run --cwd apps/portal dev", "dev:marketing": "bun run --cwd apps/marketing dev", "dev:review": "bun run --cwd apps/review dev", + "dev:debug-frontend": "bun run --cwd apps/debug-frontend dev", + "dev:debug-tui": "bun run --cwd apps/debug-tui start", + "dev:debug-stack": "bun run scripts/dev-debug-stack.ts", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", "build:opencode": "bun run --cwd apps/opencode-plugin build", "build:review": "bun run --cwd apps/review build", + "build:debug-frontend": "bun run --cwd apps/debug-frontend build", + "check:debug-frontend": "bun run --cwd apps/debug-frontend check", + "check:debug-tui": "bun run --cwd apps/debug-tui check", "build:pi": "bun run build:review && bun run build:hook && bun run --cwd apps/pi-extension build", "build": "bun run build:hook && bun run build:opencode", "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bash apps/pi-extension/vendor.sh && bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/debug-frontend/tsconfig.json && tsc --noEmit -p apps/debug-tui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/server/daemon/client.test.ts b/packages/server/daemon/client.test.ts index 2867f6f7e..247af8312 100644 --- a/packages/server/daemon/client.test.ts +++ b/packages/server/daemon/client.test.ts @@ -7,6 +7,7 @@ import { createDaemonState, getDaemonPaths, writeDaemonState } from "./state"; import { cleanupDaemonState, DaemonClient, discoverDaemon } from "./client"; let dirs: string[] = []; +const AUTH_TOKEN = "test-auth-token-test-auth-token-1234"; const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"]; const originalEnv: Record = Object.fromEntries( envKeys.map((key) => [key, process.env[key]]), @@ -41,6 +42,7 @@ function state() { hostname: "127.0.0.1", isRemote: false, remoteSource: "local", + authToken: AUTH_TOKEN, startedAt: "2026-01-01T00:00:00.000Z", }); } @@ -59,7 +61,7 @@ describe("DaemonClient", () => { await client.createSession({ request: { action: "plan", origin: "opencode", plan: "x" } }); expect(calls[0].url).toBe("http://localhost:4321/daemon/sessions"); - expect(calls[0].headers.get("authorization")).toBeNull(); + expect(calls[0].headers.get("authorization")).toBe(`Bearer ${AUTH_TOKEN}`); expect(calls[0].headers.get("content-type")).toBe("application/json"); expect(await calls[0].json()).toEqual({ request: { action: "plan", origin: "opencode", plan: "x" } }); }); diff --git a/packages/server/daemon/client.ts b/packages/server/daemon/client.ts index c9f2626c2..502324c55 100644 --- a/packages/server/daemon/client.ts +++ b/packages/server/daemon/client.ts @@ -75,6 +75,12 @@ export class DaemonClient { private async requestJson(path: string, init: RequestInit): Promise { const headers = new Headers(init.headers); if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json"); + if (path !== "/daemon/capabilities") { + const token = stateAuthToken(this.state); + if (token && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${token}`); + } + } const res = await this.fetchImpl(`${this.state.baseUrl}${path}`, { ...init, @@ -98,6 +104,20 @@ function statePid(state: unknown): number | undefined { return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : undefined; } +function stateAuthToken(state: unknown): string | undefined { + const token = (state as { authToken?: unknown } | null)?.authToken; + return typeof token === "string" && token.length > 0 ? token : undefined; +} + +function withDaemonAuth(state: unknown, headers?: HeadersInit): Headers { + const next = new Headers(headers); + const token = stateAuthToken(state); + if (token && !next.has("authorization")) { + next.set("authorization", `Bearer ${token}`); + } + return next; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -133,7 +153,9 @@ async function pollDaemonStatus( while (Date.now() < deadline) { if (pid && !isAlive(pid)) return evaluate({ kind: "pid-exited" }) ?? false; try { - const res = await fetchImpl(`${baseUrl}/daemon/status`); + const res = await fetchImpl(`${baseUrl}/daemon/status`, { + headers: withDaemonAuth(state), + }); const status = await res.json().catch(() => null) as { pid?: unknown } | null; const decision = evaluate({ kind: "status", ok: res.ok, pid: status?.pid }); if (decision !== undefined) return decision; @@ -189,7 +211,7 @@ export async function cleanupDaemonState(state: unknown, options: DaemonClientOp try { const res = await fetchImpl(`${baseUrl}/daemon/shutdown`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: withDaemonAuth(state, { "content-type": "application/json" }), body: "{}", }); endpointResponded = true; @@ -208,7 +230,7 @@ export async function cleanupDaemonState(state: unknown, options: DaemonClientOp if (await waitForDaemonReachable(state, options)) { const retry = await fetchImpl(`${baseUrl}/daemon/shutdown`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: withDaemonAuth(state, { "content-type": "application/json" }), body: "{}", }); if (!retry.ok) { diff --git a/packages/server/daemon/runtime.test.ts b/packages/server/daemon/runtime.test.ts index e4e0adfe1..527f234de 100644 --- a/packages/server/daemon/runtime.test.ts +++ b/packages/server/daemon/runtime.test.ts @@ -7,6 +7,13 @@ import { startDaemonRuntime, type DaemonRuntime } from "./runtime"; let dirs: string[] = []; let runtimes: DaemonRuntime[] = []; +const shellHtml = "Shell"; + +function daemonAuthHeaders(runtime: DaemonRuntime, headers?: HeadersInit): Headers { + const next = new Headers(headers); + next.set("authorization", `Bearer ${runtime.state.authToken}`); + return next; +} function tempBase(): string { const dir = mkdtempSync(join(tmpdir(), "plannotator-daemon-runtime-")); @@ -28,6 +35,7 @@ describe("startDaemonRuntime", () => { baseDir, hostname: "127.0.0.1", port: 0, + shellHtmlContent: shellHtml, createSession: (_request, { endpoint }) => runtime.store.create({ id: "s1", mode: "plan", @@ -53,6 +61,7 @@ describe("startDaemonRuntime", () => { baseDir, hostname: "127.0.0.1", port: 0, + shellHtmlContent: shellHtml, createSession: (_request, { endpoint }) => runtime.store.create({ id: "s1", mode: "plan", @@ -67,6 +76,7 @@ describe("startDaemonRuntime", () => { baseDir, hostname: "127.0.0.1", port: 0, + shellHtmlContent: shellHtml, createSession: () => { throw new Error("should not create"); }, @@ -79,6 +89,7 @@ describe("startDaemonRuntime", () => { baseDir, hostname: "127.0.0.1", port: 0, + shellHtmlContent: shellHtml, createSession: (_request, { endpoint }) => runtime.store.create({ id: "s1", mode: "plan", @@ -90,7 +101,7 @@ describe("startDaemonRuntime", () => { const res = await fetch(`${runtime.state.baseUrl}/daemon/shutdown`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: daemonAuthHeaders(runtime, { "content-type": "application/json" }), body: "{}", }); expect((await res.json()).shuttingDown).toBe(true); @@ -109,6 +120,7 @@ describe("startDaemonRuntime", () => { baseDir, hostname: "127.0.0.1", port: 0, + shellHtmlContent: shellHtml, createSession: (_request, { endpoint, store }) => store.create({ id: "s1", mode: "plan", @@ -125,7 +137,7 @@ describe("startDaemonRuntime", () => { try { const create = await fetch(`${runtime.state.baseUrl}/daemon/sessions`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: daemonAuthHeaders(runtime, { "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", cwd: process.cwd(), plan: "# Plan" } }), }); expect(create.status).toBe(201); diff --git a/packages/server/daemon/runtime.ts b/packages/server/daemon/runtime.ts index 39fea8730..f93be75cb 100644 --- a/packages/server/daemon/runtime.ts +++ b/packages/server/daemon/runtime.ts @@ -5,6 +5,7 @@ import { createDaemonFetchHandler, type DaemonFetchContext } from "./server"; import type { DaemonCreateSessionRequest } from "@plannotator/shared/daemon-protocol"; export interface StartDaemonRuntimeOptions extends DaemonStateOptions { + shellHtmlContent: string; createSession: ( request: DaemonCreateSessionRequest, context: DaemonFetchContext, @@ -73,6 +74,7 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr handler = createDaemonFetchHandler({ state, store, + shellHtmlContent: options.shellHtmlContent, createSession: options.createSession, onShutdown: async () => { await runtime?.stop(); diff --git a/packages/server/daemon/server.test.ts b/packages/server/daemon/server.test.ts index 4661c43e6..f15c76c8e 100644 --- a/packages/server/daemon/server.test.ts +++ b/packages/server/daemon/server.test.ts @@ -4,6 +4,36 @@ import { createDaemonState } from "./state"; import { DaemonSessionStore } from "./session-store"; import { createDaemonFetchHandler } from "./server"; +const shellHtml = "Shell"; +const legacyPlanHtml = "Plan"; +const AUTH_TOKEN = "test-auth-token-test-auth-token-1234"; + +function authHeaders(headers?: HeadersInit): Headers { + const next = new Headers(headers); + next.set("authorization", `Bearer ${AUTH_TOKEN}`); + return next; +} + +async function readSseMessage( + reader: ReadableStreamDefaultReader, +): Promise { + const decoder = new TextDecoder(); + let text = ""; + while (!text.includes("\n\n")) { + const chunk = await reader.read(); + if (chunk.done) break; + text += decoder.decode(chunk.value, { stream: true }); + } + const [message] = text.split("\n\n"); + return `${message}\n\n`; +} + +function parseSseMessage(message: string): { event: string; data: Record } { + const event = message.match(/^event: (.+)$/m)?.[1] ?? ""; + const data = message.match(/^data: (.+)$/m)?.[1] ?? "{}"; + return { event, data: JSON.parse(data) as Record }; +} + function makeHandler() { const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => 1_000 }); const state = createDaemonState({ @@ -12,10 +42,12 @@ function makeHandler() { hostname: "127.0.0.1", isRemote: false, remoteSource: "local", + authToken: AUTH_TOKEN, startedAt: "2026-01-01T00:00:00.000Z", }); const handler = createDaemonFetchHandler({ state, + shellHtmlContent: shellHtml, store, createSession: () => store.create({ id: "s1", @@ -23,7 +55,7 @@ function makeHandler() { url: `${state.baseUrl}/s/s1`, project: "repo", label: "plan-repo", - htmlContent: "Plan", + htmlContent: legacyPlanHtml, handleRequest: (_req, url) => Response.json({ path: url.pathname }), }), }); @@ -47,38 +79,153 @@ describe("daemon HTTP router", () => { expect(res.headers.get("content-type")).toContain("image/svg+xml"); }); + test("serves the frontend shell at the daemon root", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request("http://127.0.0.1:4321/")); + const text = await res.text(); + + expect(res.headers.get("content-type")).toContain("text/html"); + expect(text).toContain("Shell"); + expect(text).not.toContain("Plan"); + expect(text).not.toContain("__PLANNOTATOR_API_BASE__"); + }); + + test("bootstraps browser daemon auth through a cookie", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request(`http://127.0.0.1:4321/?plannotator_auth=${AUTH_TOKEN}`)); + + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("http://127.0.0.1:4321/"); + expect(res.headers.get("set-cookie")).toContain("plannotator_daemon_auth="); + + const sessionRes = await handler(new Request(`http://127.0.0.1:4321/s/test-session?plannotator_auth=${AUTH_TOKEN}`)); + expect(sessionRes.status).toBe(302); + expect(sessionRes.headers.get("location")).toBe("http://127.0.0.1:4321/s/test-session"); + expect(sessionRes.headers.get("set-cookie")).toContain("plannotator_daemon_auth="); + + const status = await handler(new Request("http://127.0.0.1:4321/daemon/status", { + headers: { cookie: `plannotator_daemon_auth=${AUTH_TOKEN}` }, + })); + expect(status.status).toBe(200); + }); + + test("rejects unauthenticated daemon control requests", async () => { + const { handler } = makeHandler(); + const status = await handler(new Request("http://127.0.0.1:4321/daemon/status")); + const create = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + + expect(status.status).toBe(401); + expect((await status.json()).error.code).toBe("unauthorized"); + expect(create.status).toBe(401); + expect((await create.json()).error.code).toBe("unauthorized"); + }); + test("reports daemon status with active session count", async () => { const { handler, store } = makeHandler(); await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); - const res = await handler(new Request("http://127.0.0.1:4321/daemon/status")); + const res = await handler(new Request("http://127.0.0.1:4321/daemon/status", { headers: authHeaders() })); const body = await res.json(); expect(body.pid).toBe(123); expect(body.endpoint.baseUrl).toBe("http://localhost:4321"); expect(body.activeSessionCount).toBe(1); expect(body.sessionCount).toBe(1); store.complete("s1", { approved: true }); - const afterComplete = await handler(new Request("http://127.0.0.1:4321/daemon/status")); + const afterComplete = await handler(new Request("http://127.0.0.1:4321/daemon/status", { headers: authHeaders() })); const afterCompleteBody = await afterComplete.json(); expect(afterCompleteBody.activeSessionCount).toBe(0); expect(afterCompleteBody.sessionCount).toBe(1); }); + test("streams daemon snapshot and session lifecycle events", async () => { + const { handler, store } = makeHandler(); + let timeoutDisabled = 0; + const streamResponse = await handler( + new Request("http://127.0.0.1:4321/daemon/events", { headers: authHeaders() }), + { disableIdleTimeout: () => { timeoutDisabled += 1; } }, + ); + expect(timeoutDisabled).toBe(1); + expect(streamResponse.headers.get("content-type")).toContain("text/event-stream"); + const reader = streamResponse.body!.getReader(); + + const snapshot = parseSseMessage(await readSseMessage(reader)); + expect(snapshot.event).toBe("snapshot"); + expect(snapshot.data.type).toBe("snapshot"); + expect((snapshot.data.sessions as unknown[])).toHaveLength(0); + + const status = parseSseMessage(await readSseMessage(reader)); + expect(status.event).toBe("daemon-status"); + expect((status.data.status as { activeSessionCount: number }).activeSessionCount).toBe(0); + + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const created = parseSseMessage(await readSseMessage(reader)); + expect(created.event).toBe("session-created"); + expect((created.data.session as { id: string; status: string }).id).toBe("s1"); + expect((created.data.session as { id: string; status: string }).status).toBe("active"); + + store.complete("s1", { approved: true }); + const updated = parseSseMessage(await readSseMessage(reader)); + expect(updated.event).toBe("session-updated"); + expect((updated.data.session as { status: string }).status).toBe("completed"); + + await store.delete("s1"); + const removed = parseSseMessage(await readSseMessage(reader)); + expect(removed.event).toBe("session-removed"); + expect((removed.data.session as { id: string }).id).toBe("s1"); + + await reader.cancel(); + }); + + test("broadcasts posted debug log events", async () => { + const { handler } = makeHandler(); + const streamResponse = await handler(new Request("http://127.0.0.1:4321/daemon/events", { headers: authHeaders() })); + const reader = streamResponse.body!.getReader(); + await readSseMessage(reader); + await readSseMessage(reader); + + const post = await handler(new Request("http://127.0.0.1:4321/daemon/events/debug", { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ + source: "agent-simulator", + scenarioId: "claude-plan-hook", + message: "queued claude-plan-hook", + }), + })); + expect(post.status).toBe(200); + + const debug = parseSseMessage(await readSseMessage(reader)); + expect(debug.event).toBe("debug-log"); + expect(debug.data.type).toBe("debug-log"); + expect(debug.data.source).toBe("agent-simulator"); + expect(debug.data.message).toBe("queued claude-plan-hook"); + + await reader.cancel(); + }); + test("creates and lists sessions", async () => { const { handler } = makeHandler(); const create = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); expect(create.status).toBe(201); const created = await create.json(); expect(created.session.id).toBe("s1"); - const list = await handler(new Request("http://127.0.0.1:4321/daemon/sessions")); + const list = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { headers: authHeaders() })); const body = await list.json(); expect(body.sessions).toHaveLength(1); expect(body.sessions[0].url).toBe("http://localhost:4321/s/s1"); @@ -91,7 +238,7 @@ describe("daemon HTTP router", () => { const create = await handler( new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), }), { disableIdleTimeout: () => { timeoutDisabled += 1; } }, @@ -110,10 +257,12 @@ describe("daemon HTTP router", () => { hostname: "127.0.0.1", isRemote: false, remoteSource: "local", + authToken: AUTH_TOKEN, startedAt: "2026-01-01T00:00:00.000Z", }); const handler = createDaemonFetchHandler({ state, + shellHtmlContent: shellHtml, store, createSession: () => store.create({ id: "s1", @@ -127,41 +276,82 @@ describe("daemon HTTP router", () => { await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); now = 1_101; - const list = await handler(new Request("http://127.0.0.1:4321/daemon/sessions?clean=1")); + const list = await handler(new Request("http://127.0.0.1:4321/daemon/sessions?clean=1", { headers: authHeaders() })); const body = await list.json(); expect(body.sessions).toHaveLength(0); expect(store.get("s1")).toBeUndefined(); }); - test("serves session HTML with API base injection", async () => { + test("serves session shell HTML with API base injection", async () => { const { handler } = makeHandler(); await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const res = await handler(new Request("http://127.0.0.1:4321/s/s1")); const html = await res.text(); expect(html).toContain("window.__PLANNOTATOR_API_BASE__ = apiBase"); expect(html).toContain('apiBase = "/s/s1/api"'); + expect(html).toContain("Shell"); + expect(html).not.toContain("Plan"); expect(html).toContain("window.fetch"); expect(html).toContain("window.EventSource"); expect(html).toContain("input instanceof Request"); expect(html).toContain("window.EventSource.OPEN = OriginalEventSource.OPEN"); - expect(html.indexOf("window.__PLANNOTATOR_API_BASE__")).toBeGreaterThan(html.indexOf("const literal")); + expect(html.indexOf("window.__PLANNOTATOR_API_BASE__")).toBeGreaterThan(html.indexOf("shellLiteral")); expect(html.indexOf("window.__PLANNOTATOR_API_BASE__")).toBeLessThan(html.indexOf("")); }); + test.each(["plan", "review", "annotate", "archive", "setup-goal"] as const)( + "serves the same frontend shell for %s session pages", + async (mode) => { + const store = new DaemonSessionStore({ now: () => 1_000 }); + const state = createDaemonState({ + pid: 123, + port: 4321, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + authToken: AUTH_TOKEN, + startedAt: "2026-01-01T00:00:00.000Z", + }); + store.create({ + id: mode, + mode, + url: `${state.baseUrl}/s/${mode}`, + project: "repo", + label: `${mode}-repo`, + htmlContent: `Legacy ${mode}`, + }); + const handler = createDaemonFetchHandler({ + state, + shellHtmlContent: shellHtml, + store, + createSession: () => { + throw new Error("not used"); + }, + }); + + const res = await handler(new Request(`http://127.0.0.1:4321/s/${mode}`)); + const text = await res.text(); + + expect(text).toContain("Shell"); + expect(text).toContain(`apiBase = "/s/${mode}/api"`); + expect(text).not.toContain(`Legacy ${mode}`); + }, + ); + test("routes session-scoped API paths to the owning session", async () => { const { handler } = makeHandler(); await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const res = await handler(new Request("http://127.0.0.1:4321/s/s1/api/plan")); @@ -169,11 +359,94 @@ describe("daemon HTTP router", () => { expect(body.path).toBe("/api/plan"); }); + test("serves session bootstrap before delegating to the session handler", async () => { + const { handler, store } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + let routed = 0; + const record = store.get("s1"); + if (record) { + record.handleRequest = () => { + routed += 1; + return Response.json({ routed: true }); + }; + } + + const res = await handler(new Request("http://127.0.0.1:4321/s/s1/api/session")); + const body = await res.json(); + + expect(routed).toBe(0); + expect(body.ok).toBe(true); + expect(body.session.id).toBe("s1"); + expect(body.session.mode).toBe("plan"); + expect(body.apiBase).toBe("/s/s1/api"); + expect(body.capabilities.protocol).toBe(PLANNOTATOR_DAEMON_PROTOCOL); + expect(body.supportedSessionViews).toContain("plan"); + expect(body.supportedSessionViews).toContain("setup-goal"); + }); + + test("returns a daemon error for missing session bootstrap", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request("http://127.0.0.1:4321/s/missing/api/session")); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("session-not-found"); + }); + + test("serves shell HTML for missing session page routes", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request("http://127.0.0.1:4321/s/missing")); + const text = await res.text(); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(text).toContain("Shell"); + expect(text).toContain('apiBase = "/s/missing/api"'); + }); + + test("does not serve shell HTML for non-page session requests", async () => { + const { handler } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + + const existing = await handler(new Request("http://127.0.0.1:4321/s/s1/not-api", { + method: "POST", + body: "{}", + })); + const missing = await handler(new Request("http://127.0.0.1:4321/s/missing", { + method: "POST", + body: "{}", + })); + + expect(existing.status).toBe(404); + expect(await existing.text()).not.toContain("Shell"); + expect(missing.status).toBe(404); + expect(await missing.text()).not.toContain("Shell"); + }); + + test("returns daemon errors for missing session API routes", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request("http://127.0.0.1:4321/s/missing/api/plan")); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("session-not-found"); + }); + test("does not route session paths that only prefix-match api", async () => { const { handler, store } = makeHandler(); await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); let routed = 0; @@ -190,7 +463,8 @@ describe("daemon HTTP router", () => { expect(routed).toBe(0); expect(res.headers.get("content-type")).toContain("text/html"); - expect(text).toContain("Plan"); + expect(text).toContain("Shell"); + expect(text).not.toContain("Plan"); }); test("passes request context through session-scoped API paths", async () => { @@ -198,7 +472,7 @@ describe("daemon HTTP router", () => { let timeoutDisabled = 0; await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const record = store.get("s1"); @@ -221,7 +495,7 @@ describe("daemon HTTP router", () => { const { handler } = makeHandler(); await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const res = await handler(new Request("http://127.0.0.1:4321/api/plan", { @@ -234,7 +508,7 @@ describe("daemon HTTP router", () => { const { handler } = makeHandler(); const res = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "text/plain" }, + headers: authHeaders({ "content-type": "text/plain" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const body = await res.json(); @@ -246,17 +520,17 @@ describe("daemon HTTP router", () => { const { handler, store } = makeHandler(); await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const cancel = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/cancel", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: "{}", })); expect((await cancel.json()).session.status).toBe("cancelled"); - const result = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/result")); + const result = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/result", { headers: authHeaders() })); const body = await result.json(); expect(body.session.status).toBe("cancelled"); expect(body.session.error).toBe("Session cancelled."); @@ -268,12 +542,12 @@ describe("daemon HTTP router", () => { let timeoutDisabled = 0; await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const resultPromise = handler( - new Request("http://127.0.0.1:4321/daemon/sessions/s1/result"), + new Request("http://127.0.0.1:4321/daemon/sessions/s1/result", { headers: authHeaders() }), { disableIdleTimeout: () => { timeoutDisabled += 1; } }, ); store.complete("s1", { approved: true }); @@ -287,15 +561,17 @@ describe("daemon HTTP router", () => { const { handler } = makeHandler(); await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), })); const cancel = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/cancel", { method: "POST", + headers: authHeaders(), })); const shutdown = await handler(new Request("http://127.0.0.1:4321/daemon/shutdown", { method: "POST", + headers: authHeaders(), })); expect(cancel.status).toBe(415); diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index f945a5cf2..f7f256a7c 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -1,19 +1,28 @@ import { + PLANNOTATOR_DAEMON_SESSION_VIEWS, createDaemonErrorResponse, getDaemonCapabilities, + serializeDaemonEvent, type DaemonCreateSessionRequest, type DaemonEndpoint, + type DaemonEvent, + type DaemonSessionBootstrapResponse, type DaemonStatus, } from "@plannotator/shared/daemon-protocol"; import type { DaemonState } from "./state"; +import { DAEMON_AUTH_COOKIE, DAEMON_AUTH_QUERY_PARAM } from "./state"; import { DaemonSessionStore, type DaemonSessionRecord } from "./session-store"; import type { SessionRequestContext } from "../session-handler"; import { handleFavicon } from "../shared-handlers"; const RESULT_DELETE_GRACE_MS = 2_000; +const DAEMON_EVENT_HEARTBEAT_MS = 15_000; +const SSE_HEARTBEAT_COMMENT = ": heartbeat\n\n"; +const DAEMON_AUTH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; export interface DaemonServerOptions { state: DaemonState; + shellHtmlContent: string; store?: DaemonSessionStore; createSession: ( request: DaemonCreateSessionRequest, @@ -52,6 +61,52 @@ function isJsonRequest(req: Request): boolean { return contentType.split(";")[0].trim().toLowerCase() === "application/json"; } +function isPageRequest(req: Request): boolean { + return req.method === "GET" || req.method === "HEAD"; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function bearerToken(req: Request): string | undefined { + const header = req.headers.get("authorization") ?? ""; + const match = header.match(/^Bearer\s+(.+)$/i); + return match?.[1]; +} + +function cookieToken(req: Request): string | undefined { + const cookie = req.headers.get("cookie") ?? ""; + for (const part of cookie.split(";")) { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName === DAEMON_AUTH_COOKIE) { + return decodeURIComponent(rawValue.join("=")); + } + } + return undefined; +} + +function hasDaemonAuth(req: Request, state: DaemonState): boolean { + return bearerToken(req) === state.authToken || cookieToken(req) === state.authToken; +} + +function daemonAuthCookie(state: DaemonState): string { + return [ + `${DAEMON_AUTH_COOKIE}=${encodeURIComponent(state.authToken)}`, + "Path=/", + "HttpOnly", + "SameSite=Strict", + `Max-Age=${DAEMON_AUTH_COOKIE_MAX_AGE_SECONDS}`, + ].join("; "); +} + +function daemonUnauthorized(): Response { + return json( + createDaemonErrorResponse("unauthorized", "Daemon control request is missing or using an invalid auth token."), + { status: 401 }, + ); +} + function injectApiBase(html: string, apiBaseScript: string): string { const marker = ""; const index = html.lastIndexOf(marker); @@ -60,9 +115,10 @@ function injectApiBase(html: string, apiBaseScript: string): string { } function createApiBaseScript(apiBase: string): string { + const safeApiBase = JSON.stringify(apiBase).replace(/ (() => { - const apiBase = ${JSON.stringify(apiBase)}; + const apiBase = ${safeApiBase}; window.__PLANNOTATOR_API_BASE__ = apiBase; const isApiPath = (path) => path === "/api" || path.startsWith("/api/"); @@ -103,6 +159,25 @@ function createApiBaseScript(apiBase: string): string { `; } +function html(htmlContent: string): Response { + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); +} + +function sessionShellHtml(shellHtmlContent: string, sessionId: string): string { + const apiBase = `/s/${sessionId}/api`; + return injectApiBase(shellHtmlContent, createApiBaseScript(apiBase)); +} + +function daemonEventHeaders(): HeadersInit { + return { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }; +} + export function createDaemonFetchHandler(options: DaemonServerOptions) { const store = options.store ?? new DaemonSessionStore(); const endpoint: DaemonEndpoint = { @@ -113,6 +188,47 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) { }; const context: DaemonFetchContext = { endpoint, store }; + const encoder = new TextEncoder(); + const subscribers = new Map< + ReadableStreamDefaultController, + ReturnType + >(); + + const makeStatus = (): DaemonStatus => ({ + ok: true, + protocol: options.state.protocol, + protocolVersion: options.state.protocolVersion, + pid: options.state.pid, + endpoint, + startedAt: options.state.startedAt, + activeSessionCount: store.activeCount(), + sessionCount: store.totalCount(), + }); + + const sendEvent = ( + controller: ReadableStreamDefaultController, + event: DaemonEvent, + ) => { + controller.enqueue(encoder.encode(serializeDaemonEvent(event))); + }; + + const removeSubscriber = (controller: ReadableStreamDefaultController) => { + const timer = subscribers.get(controller); + if (timer) clearInterval(timer); + subscribers.delete(controller); + }; + + const broadcast = (event: DaemonEvent) => { + for (const controller of subscribers.keys()) { + try { + sendEvent(controller, event); + } catch { + removeSubscriber(controller); + } + } + }; + + store.onMutation((event) => broadcast(event)); return async function daemonFetch(req: Request, requestContext?: SessionRequestContext): Promise { const url = new URL(req.url); @@ -125,18 +241,97 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) { return handleFavicon(); } + if (isPageRequest(req) && url.searchParams.has(DAEMON_AUTH_QUERY_PARAM)) { + const token = url.searchParams.get(DAEMON_AUTH_QUERY_PARAM); + if (token !== options.state.authToken) return daemonUnauthorized(); + url.searchParams.delete(DAEMON_AUTH_QUERY_PARAM); + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + "Set-Cookie": daemonAuthCookie(options.state), + }, + }); + } + + if (url.pathname === "/" && isPageRequest(req)) { + return html(options.shellHtmlContent); + } + + if (url.pathname.startsWith("/daemon/") && !hasDaemonAuth(req, options.state)) { + return daemonUnauthorized(); + } + + if (url.pathname === "/daemon/events" && req.method === "GET") { + requestContext?.disableIdleTimeout?.(); + let streamController: ReadableStreamDefaultController | undefined; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(SSE_HEARTBEAT_COMMENT)); + } catch { + removeSubscriber(controller); + } + }, DAEMON_EVENT_HEARTBEAT_MS); + heartbeat.unref?.(); + subscribers.set(controller, heartbeat); + + const at = new Date().toISOString(); + const status = makeStatus(); + sendEvent(controller, { + type: "snapshot", + at, + status, + sessions: store.list(), + }); + sendEvent(controller, { + type: "daemon-status", + at, + status, + }); + }, + cancel() { + if (streamController) removeSubscriber(streamController); + }, + }); + return new Response(stream, { headers: daemonEventHeaders() }); + } + + if (url.pathname === "/daemon/events/debug" && req.method === "POST") { + if (!isJsonRequest(req)) { + return json(createDaemonErrorResponse("invalid-request", "Daemon debug events must use application/json."), { status: 415 }); + } + let body: Record; + try { + body = await req.json() as Record; + } catch { + return json(createDaemonErrorResponse("invalid-request", "Invalid daemon debug event JSON."), { status: 400 }); + } + const message = optionalString(body.message); + if (!message) { + return json(createDaemonErrorResponse("invalid-request", "Daemon debug events require a message."), { status: 400 }); + } + const rawLevel = optionalString(body.level); + const level = rawLevel === "debug" || rawLevel === "info" || rawLevel === "warn" || rawLevel === "error" + ? rawLevel + : "info"; + broadcast({ + type: "debug-log", + at: optionalString(body.at) ?? new Date().toISOString(), + source: optionalString(body.source) ?? "external", + message, + level, + sessionId: optionalString(body.sessionId), + scenarioId: optionalString(body.scenarioId), + data: body.data, + }); + return json({ ok: true }); + } + if (url.pathname === "/daemon/status" && req.method === "GET") { - const status: DaemonStatus = { - ok: true, - protocol: options.state.protocol, - protocolVersion: options.state.protocolVersion, - pid: options.state.pid, - endpoint, - startedAt: options.state.startedAt, - activeSessionCount: store.activeCount(), - sessionCount: store.totalCount(), - }; - return json(status); + return json(makeStatus()); } if (url.pathname === "/daemon/sessions" && req.method === "GET") { @@ -161,8 +356,15 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) { const record = await options.createSession(body, context); return json({ ok: true, session: store.summary(record, { includeRemoteShare: true }) }, { status: 201 }); } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create session."; + broadcast({ + type: "daemon-error", + at: new Date().toISOString(), + code: "internal-error", + message, + }); return json( - createDaemonErrorResponse("internal-error", err instanceof Error ? err.message : "Failed to create session."), + createDaemonErrorResponse("internal-error", message), { status: 500 }, ); } @@ -224,10 +426,29 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) { const browserSession = sessionFromPath(url.pathname); if (browserSession) { const record = store.get(browserSession.id); + const sessionApiPath = `/s/${browserSession.id}/api`; if (!record) { - return new Response("Session not found", { status: 404 }); + if (url.pathname === `${sessionApiPath}/session` && req.method === "GET") { + return json(createDaemonErrorResponse("session-not-found", `Session not found: ${browserSession.id}`), { status: 404 }); + } + if (url.pathname === sessionApiPath || url.pathname.startsWith(`${sessionApiPath}/`)) { + return json(createDaemonErrorResponse("session-not-found", `Session not found: ${browserSession.id}`), { status: 404 }); + } + if (isPageRequest(req)) { + return html(sessionShellHtml(options.shellHtmlContent, browserSession.id)); + } + return new Response("Not found", { status: 404 }); + } + if (url.pathname === `${sessionApiPath}/session` && req.method === "GET") { + const bootstrap: DaemonSessionBootstrapResponse = { + ok: true, + session: store.summary(record, { includeRemoteShare: true }), + apiBase: sessionApiPath, + capabilities: getDaemonCapabilities(), + supportedSessionViews: [...PLANNOTATOR_DAEMON_SESSION_VIEWS], + }; + return json(bootstrap); } - const sessionApiPath = `/s/${browserSession.id}/api`; if (url.pathname === sessionApiPath || url.pathname.startsWith(`${sessionApiPath}/`)) { if (!record.handleRequest) { return new Response("Session has no API handler", { status: 404 }); @@ -235,13 +456,10 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) { const scopedUrl = stripSessionApiPath(url, browserSession.id); return record.handleRequest(new Request(scopedUrl.toString(), req), scopedUrl, requestContext); } - if (record.htmlContent) { - const apiBase = `/s/${record.id}/api`; - const apiBaseScript = createApiBaseScript(apiBase); - return new Response(injectApiBase(record.htmlContent, apiBaseScript), { - headers: { "Content-Type": "text/html" }, - }); + if (isPageRequest(req)) { + return html(sessionShellHtml(options.shellHtmlContent, record.id)); } + return new Response("Not found", { status: 404 }); } return new Response("Not found", { status: 404 }); diff --git a/packages/server/daemon/session-factory.test.ts b/packages/server/daemon/session-factory.test.ts index 94081bca6..d38a81ba0 100644 --- a/packages/server/daemon/session-factory.test.ts +++ b/packages/server/daemon/session-factory.test.ts @@ -64,6 +64,7 @@ describe("createDaemonSessionFactory", () => { }, context); expect(record.expiresAt).toBeDefined(); + expect(record.htmlContent).toBeUndefined(); const planResponse = await record.handleRequest!( new Request("http://127.0.0.1:4321/api/plan"), diff --git a/packages/server/daemon/session-factory.ts b/packages/server/daemon/session-factory.ts index 96807b8b9..93a047aa3 100644 --- a/packages/server/daemon/session-factory.ts +++ b/packages/server/daemon/session-factory.ts @@ -19,14 +19,11 @@ import { createWorktree, ensureObjectAvailable, fetchRef } from "@plannotator/sh import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; import type { PluginAnnotateRequest, - PluginGoalSetupRequest, PluginPlanRequest, PluginReviewRequest, } from "@plannotator/shared/plugin-protocol"; -import { normalizeGoalSetupBundle } from "@plannotator/shared/goal-setup"; import { createPlannotatorSession } from "../index"; import { createAnnotateSession } from "../annotate"; -import { createGoalSetupSession } from "../goal-setup"; import { createReviewSession } from "../review"; import { detectProjectName } from "../project"; import { createRemoteShareNotice } from "../share-url"; @@ -516,7 +513,6 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) label: `plugin-plan-${request.origin}-${project}`, origin: request.origin, ttlMs, - htmlContent: session.htmlContent, handleRequest: session.handleRequest, dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), remoteShare, @@ -544,7 +540,6 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) label: `plugin-archive-${request.origin}-${project}`, origin: request.origin, ttlMs, - htmlContent: session.htmlContent, handleRequest: session.handleRequest, dispose: registerSessionDecision( context, @@ -581,7 +576,6 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) : `plugin-annotate-${request.origin}-${input.mode === "annotate-last" ? "last" : basename(input.filePath)}`, origin: request.origin, ttlMs, - htmlContent: session.htmlContent, handleRequest: session.handleRequest, dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose(), (result) => ({ ...result, @@ -636,7 +630,6 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) : `plugin-review-${request.origin}-${project}`, origin: request.origin, ttlMs, - htmlContent: session.htmlContent, handleRequest: session.handleRequest, dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), remoteShare, @@ -644,29 +637,6 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) return record; } - if (request.action === "goal-setup") { - const bundle = normalizeGoalSetupBundle(request.bundle, request.stage); - const session = await createGoalSetupSession({ - cwd, - bundle, - origin: request.origin, - htmlContent: options.planHtmlContent, - }); - const record = context.store.create({ - id, - mode: "goal-setup", - url, - project, - label: `goal-setup-${bundle.stage}-${request.goalSlug || project}`, - origin: request.origin, - ttlMs, - htmlContent: session.htmlContent, - handleRequest: session.handleRequest, - dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), - }); - return record; - } - throw new Error(`Unsupported daemon session action: ${(request as { action?: string }).action}`); }; } diff --git a/packages/server/daemon/session-store.test.ts b/packages/server/daemon/session-store.test.ts index c22a64bf4..553b12c88 100644 --- a/packages/server/daemon/session-store.test.ts +++ b/packages/server/daemon/session-store.test.ts @@ -33,7 +33,53 @@ describe("DaemonSessionStore", () => { ]); }); - test("waiters resolve when a session completes and routing payloads are retained for result delivery", async () => { + test("emits lifecycle events for created, updated, and removed sessions", async () => { + let now = 1_000; + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => now }); + const events: Array<{ type: string; sessionId: string; status: string; at: string }> = []; + store.onMutation((event) => { + events.push({ + type: event.type, + sessionId: event.session.id, + status: event.session.status, + at: event.at, + }); + }); + + store.create({ + mode: "plan", + url: "http://localhost:1234/s/s1", + project: "repo", + label: "plan-repo", + }); + now = 2_000; + store.complete("s1", { approved: true }); + now = 3_000; + await store.delete("s1"); + + expect(events).toEqual([ + { + type: "session-created", + sessionId: "s1", + status: "active", + at: "1970-01-01T00:00:01.000Z", + }, + { + type: "session-updated", + sessionId: "s1", + status: "completed", + at: "1970-01-01T00:00:02.000Z", + }, + { + type: "session-removed", + sessionId: "s1", + status: "completed", + at: "1970-01-01T00:00:03.000Z", + }, + ]); + }); + + test("waiters resolve when a session completes and routing payloads are released", async () => { let now = 1_000; const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => now }); let disposed = false; @@ -58,12 +104,12 @@ describe("DaemonSessionStore", () => { expect(store.activeCount()).toBe(0); expect(store.list()).toEqual([]); expect(disposed).toBe(true); - expect(store.get("s1")?.htmlContent).toBe(""); - expect(store.get("s1")?.handleRequest).toBeDefined(); + expect(store.get("s1")?.htmlContent).toBeUndefined(); + expect(store.get("s1")?.handleRequest).toBeUndefined(); expect(store.get("s1")?.dispose).toBeUndefined(); }); - test("failed sessions dispose resources while retaining result delivery payloads", async () => { + test("failed sessions dispose resources and release routing payloads", async () => { const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => 1_000 }); let disposed = false; store.create({ @@ -82,8 +128,8 @@ describe("DaemonSessionStore", () => { expect(result.status).toBe("failed"); expect(result.error).toBe("Boom."); expect(disposed).toBe(true); - expect(store.get("s1")?.htmlContent).toBe(""); - expect(store.get("s1")?.handleRequest).toBeDefined(); + expect(store.get("s1")?.htmlContent).toBeUndefined(); + expect(store.get("s1")?.handleRequest).toBeUndefined(); }); test("waiters resolve when a session is cancelled", async () => { diff --git a/packages/server/daemon/session-store.ts b/packages/server/daemon/session-store.ts index 129c8c0bd..4f2b3e88b 100644 --- a/packages/server/daemon/session-store.ts +++ b/packages/server/daemon/session-store.ts @@ -1,5 +1,6 @@ import type { DaemonRemoteShareNotice, + DaemonSessionEvent, DaemonSessionMode, DaemonSessionStatus, DaemonSessionSummary, @@ -52,6 +53,8 @@ type Waiter = { reject: (err: Error) => void; }; +export type DaemonSessionStoreListener = (event: DaemonSessionEvent) => void; + const TERMINAL_STATUSES = new Set([ "completed", "cancelled", @@ -71,6 +74,7 @@ export function createDaemonSessionId(): string { export class DaemonSessionStore { private sessions = new Map(); private waiters = new Map[]>(); + private listeners = new Set(); private readonly idFactory: () => string; private readonly now: () => number; @@ -79,6 +83,13 @@ export class DaemonSessionStore { this.now = options.now ?? (() => Date.now()); } + onMutation(listener: DaemonSessionStoreListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + create(input: CreateDaemonSessionInput): DaemonSessionRecord { const now = input.now ?? this.now(); const id = input.id ?? this.idFactory(); @@ -100,6 +111,7 @@ export class DaemonSessionStore { ...(input.remoteShare && { remoteShare: input.remoteShare }), }; this.sessions.set(id, record); + this.emit("session-created", record); if (TERMINAL_STATUSES.has(record.status)) this.resolveWaiters(record); return record; } @@ -149,7 +161,9 @@ export class DaemonSessionStore { record.updatedAt = iso(now); record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); this.resolveWaiters(record); + this.emit("session-updated", record); void this.disposeResources(record); + this.releaseRoutingPayloads(record); return record; } @@ -162,7 +176,9 @@ export class DaemonSessionStore { record.updatedAt = iso(now); record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); this.resolveWaiters(record); + this.emit("session-updated", record); void this.disposeResources(record); + this.releaseRoutingPayloads(record); return record; } @@ -175,6 +191,7 @@ export class DaemonSessionStore { record.updatedAt = iso(now); record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); this.resolveWaiters(record); + this.emit("session-updated", record); await this.disposeRecord(record); return record; } @@ -196,6 +213,7 @@ export class DaemonSessionStore { this.sessions.delete(id); this.rejectWaiters(id, new Error(`Session deleted: ${id}`)); await this.disposeRecord(record); + this.emit("session-removed", record); return true; } @@ -214,6 +232,7 @@ export class DaemonSessionStore { record.updatedAt = iso(now); expired.push(record); this.resolveWaiters(record); + this.emit("session-updated", record); await this.removeRecord(record); } return expired; @@ -229,11 +248,22 @@ export class DaemonSessionStore { record.updatedAt = iso(now); record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); this.resolveWaiters(record); + this.emit("session-updated", record); } await this.disposeRecord(record); } } + private emit(type: DaemonSessionEvent["type"], record: DaemonSessionRecord): void { + if (this.listeners.size === 0) return; + const event: DaemonSessionEvent = { + type, + at: iso(this.now()), + session: this.summary(record, { includeRemoteShare: true }), + }; + for (const listener of this.listeners) listener(event); + } + private resolveWaiters(record: DaemonSessionRecord): void { const waiters = this.waiters.get(record.id) ?? []; this.waiters.delete(record.id); @@ -249,12 +279,12 @@ export class DaemonSessionStore { private async removeRecord(record: DaemonSessionRecord): Promise { this.sessions.delete(record.id); await this.disposeRecord(record); + this.emit("session-removed", record); } private async disposeRecord(record: DaemonSessionRecord): Promise { await this.disposeResources(record); - record.htmlContent = undefined; - record.handleRequest = undefined; + this.releaseRoutingPayloads(record); } private async disposeResources(record: DaemonSessionRecord): Promise { @@ -268,4 +298,9 @@ export class DaemonSessionStore { // Best-effort cleanup; callers observe session status separately. } } + + private releaseRoutingPayloads(record: DaemonSessionRecord): void { + record.htmlContent = undefined; + record.handleRequest = undefined; + } } diff --git a/packages/server/daemon/state.ts b/packages/server/daemon/state.ts index 802088f27..1b316e29a 100644 --- a/packages/server/daemon/state.ts +++ b/packages/server/daemon/state.ts @@ -2,10 +2,14 @@ import { PLANNOTATOR_DAEMON_PROTOCOL, PLANNOTATOR_DAEMON_PROTOCOL_VERSION, } from "@plannotator/shared/daemon-protocol"; -import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, closeSync, statSync, type Stats } from "fs"; +import { randomBytes } from "crypto"; +import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, closeSync, statSync, type Stats } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; +export const DAEMON_AUTH_QUERY_PARAM = "plannotator_auth"; +export const DAEMON_AUTH_COOKIE = "plannotator_daemon_auth"; + export interface DaemonState { protocol: typeof PLANNOTATOR_DAEMON_PROTOCOL; protocolVersion: typeof PLANNOTATOR_DAEMON_PROTOCOL_VERSION; @@ -16,6 +20,7 @@ export interface DaemonState { startedAt: string; isRemote: boolean; remoteSource: "env" | "ssh" | "local"; + authToken: string; requestedPort?: number; binaryVersion?: string; } @@ -57,6 +62,16 @@ function defaultIsAlive(pid: number): boolean { } } +export function createDaemonAuthToken(): string { + return randomBytes(32).toString("hex"); +} + +export function createDaemonBrowserAuthUrl(state: DaemonState, pathname = "/"): string { + const url = new URL(pathname, state.baseUrl); + url.searchParams.set(DAEMON_AUTH_QUERY_PARAM, state.authToken); + return url.toString(); +} + export function getDaemonPaths(options: DaemonStateOptions = {}): DaemonPaths { const dir = options.baseDir ?? join(homedir(), ".plannotator"); return { @@ -82,7 +97,9 @@ export function isDaemonState(value: unknown): value is DaemonState { typeof state.hostname === "string" && typeof state.baseUrl === "string" && typeof state.startedAt === "string" && - typeof state.isRemote === "boolean" + typeof state.isRemote === "boolean" && + typeof state.authToken === "string" && + state.authToken.length >= 32 ); } @@ -115,8 +132,16 @@ export function readDaemonState(options: DaemonStateOptions = {}): DaemonStateRe export function writeDaemonState(state: DaemonState, options: DaemonStateOptions = {}): void { const paths = getDaemonPaths(options); - mkdirSync(dirname(paths.statePath), { recursive: true }); - writeFileSync(paths.statePath, JSON.stringify(state, null, 2), "utf-8"); + mkdirSync(dirname(paths.statePath), { recursive: true, mode: 0o700 }); + writeFileSync(paths.statePath, JSON.stringify(state, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + try { + chmodSync(paths.statePath, 0o600); + } catch { + // Best-effort on platforms/filesystems that do not support POSIX modes. + } } export function removeDaemonState(options: DaemonStateOptions = {}): void { @@ -220,6 +245,7 @@ export function createDaemonState(input: { hostname: string; isRemote: boolean; remoteSource: DaemonState["remoteSource"]; + authToken?: string; startedAt?: string; binaryVersion?: string; requestedPort?: number; @@ -237,6 +263,7 @@ export function createDaemonState(input: { startedAt: input.startedAt ?? new Date().toISOString(), isRemote: input.isRemote, remoteSource: input.remoteSource, + authToken: input.authToken ?? createDaemonAuthToken(), ...(input.binaryVersion && { binaryVersion: input.binaryVersion }), ...(input.requestedPort !== undefined && { requestedPort: input.requestedPort }), }; diff --git a/packages/shared/daemon-protocol.test.ts b/packages/shared/daemon-protocol.test.ts index 2e1c73db1..7e8e261c0 100644 --- a/packages/shared/daemon-protocol.test.ts +++ b/packages/shared/daemon-protocol.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from "bun:test"; import { + PLANNOTATOR_DAEMON_FEATURES, PLANNOTATOR_DAEMON_PROTOCOL, PLANNOTATOR_DAEMON_PROTOCOL_VERSION, + PLANNOTATOR_DAEMON_SESSION_VIEWS, createDaemonErrorResponse, getDaemonCapabilities, isCompatibleDaemonCapabilities, + serializeDaemonEvent, + type DaemonEvent, } from "./daemon-protocol"; describe("daemon protocol", () => { @@ -15,7 +19,18 @@ describe("daemon protocol", () => { expect(capabilities.transport).toBe("http"); expect(capabilities.multiSession).toBe(true); expect(capabilities.features).toContain("session-create"); + expect(capabilities.features).toContain("session-bootstrap"); expect(capabilities.features).toContain("session-result-wait"); + expect(capabilities.features).toContain("events"); + expect(capabilities.features).toContain("debug-events"); + expect(PLANNOTATOR_DAEMON_FEATURES).toContain("session-bootstrap"); + expect(PLANNOTATOR_DAEMON_SESSION_VIEWS).toEqual([ + "plan", + "review", + "annotate", + "archive", + "setup-goal", + ]); }); test("validates compatible capabilities", () => { @@ -32,4 +47,17 @@ describe("daemon protocol", () => { expect(response.error.code).toBe("daemon-unreachable"); expect(response.error.message).toBe("No daemon"); }); + + test("serializes daemon events as named SSE messages", () => { + const event: DaemonEvent = { + type: "daemon-error", + at: "2026-05-17T00:00:00.000Z", + code: "internal-error", + message: "Boom", + }; + + expect(serializeDaemonEvent(event)).toBe( + `event: daemon-error\ndata: ${JSON.stringify(event)}\n\n`, + ); + }); }); diff --git a/packages/shared/daemon-protocol.ts b/packages/shared/daemon-protocol.ts index 7dc423fb7..9477bd1d6 100644 --- a/packages/shared/daemon-protocol.ts +++ b/packages/shared/daemon-protocol.ts @@ -9,15 +9,26 @@ export const PLANNOTATOR_DAEMON_FEATURES = [ "status", "sessions", "session-create", + "session-bootstrap", "session-result-wait", "session-cancel", "shutdown", + "events", + "debug-events", +] as const; + +export const PLANNOTATOR_DAEMON_SESSION_VIEWS = [ + "plan", + "review", + "annotate", + "archive", + "setup-goal", ] as const; export type DaemonFeature = (typeof PLANNOTATOR_DAEMON_FEATURES)[number]; export type DaemonSessionMode = PluginSessionMode; +export type DaemonSessionView = (typeof PLANNOTATOR_DAEMON_SESSION_VIEWS)[number]; export type DaemonSessionStatus = - | "pending" | "active" | "completed" | "cancelled" @@ -82,6 +93,14 @@ export interface DaemonCreateSessionResponse { session: DaemonSessionSummary; } +export interface DaemonSessionBootstrapResponse { + ok: true; + session: DaemonSessionSummary; + apiBase: string; + capabilities: DaemonCapabilities; + supportedSessionViews: DaemonSessionView[]; +} + export interface DaemonSessionResultResponse { ok: true; session: DaemonSessionSummary; @@ -107,6 +126,7 @@ export type DaemonErrorCode = | "session-not-found" | "session-cancelled" | "session-expired" + | "unauthorized" | "invalid-request" | "internal-error"; @@ -122,6 +142,55 @@ export interface DaemonErrorResponse { export type DaemonResponse = T | DaemonErrorResponse; +export type DaemonEventType = + | "snapshot" + | "daemon-status" + | "session-created" + | "session-updated" + | "session-removed" + | "daemon-error" + | "debug-log"; + +export type DaemonEvent = + | { + type: "snapshot"; + at: string; + status: DaemonStatus; + sessions: DaemonSessionSummary[]; + } + | { + type: "daemon-status"; + at: string; + status: DaemonStatus; + } + | { + type: "session-created" | "session-updated" | "session-removed"; + at: string; + session: DaemonSessionSummary; + } + | { + type: "daemon-error"; + at: string; + code: DaemonErrorCode | string; + message: string; + sessionId?: string; + } + | { + type: "debug-log"; + at: string; + source: string; + message: string; + level?: "debug" | "info" | "warn" | "error"; + sessionId?: string; + scenarioId?: string; + data?: unknown; + }; + +export type DaemonSessionEvent = Extract< + DaemonEvent, + { type: "session-created" | "session-updated" | "session-removed" } +>; + export function getDaemonCapabilities(): DaemonCapabilities { return { protocol: PLANNOTATOR_DAEMON_PROTOCOL, @@ -145,6 +214,10 @@ export function createDaemonErrorResponse( }; } +export function serializeDaemonEvent(event: DaemonEvent): string { + return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; +} + export function isCompatibleDaemonCapabilities( value: unknown, ): value is DaemonCapabilities { diff --git a/packages/shared/plugin-binary.test.ts b/packages/shared/plugin-binary.test.ts index 8f525cfe1..5e3861cca 100644 --- a/packages/shared/plugin-binary.test.ts +++ b/packages/shared/plugin-binary.test.ts @@ -55,6 +55,10 @@ describe("discoverPlannotatorBinary", () => { sourceRoot: "/repo/plannotator", exists: existsOnly([ "/repo/plannotator/bin/plannotator.js", + "/repo/plannotator/apps/hook/server/index.ts", + "/repo/plannotator/apps/hook/dist/index.html", + "/repo/plannotator/apps/hook/dist/review.html", + "/repo/plannotator/apps/debug-frontend/dist/index.html", "/old/plannotator", ]), platform: "linux", @@ -75,6 +79,10 @@ describe("discoverPlannotatorBinary", () => { sourceRoot: "C:\\repo\\plannotator", exists: existsOnly([ "C:\\repo\\plannotator/bin/plannotator.cmd", + "C:\\repo\\plannotator/apps/hook/server/index.ts", + "C:\\repo\\plannotator/apps/hook/dist/index.html", + "C:\\repo\\plannotator/apps/hook/dist/review.html", + "C:\\repo\\plannotator/apps/debug-frontend/dist/index.html", "C:\\Old/plannotator.exe", ]), platform: "win32", @@ -100,6 +108,27 @@ describe("discoverPlannotatorBinary", () => { )).toBe("/repo/plannotator"); }); + test("skips an unbuilt source checkout shim", () => { + const result = discoverPlannotatorBinary({ + env: { PATH: "/old" }, + homeDir: "/home/test", + sourceRoot: "/repo/plannotator", + exists: existsOnly([ + "/repo/plannotator/bin/plannotator.js", + "/repo/plannotator/apps/hook/server/index.ts", + "/old/plannotator", + ]), + platform: "linux", + pathDelimiter: ":", + }); + + expect(result).toMatchObject({ + found: true, + path: "/old/plannotator", + source: "path", + }); + }); + test("falls back to standard install location", () => { const result = discoverPlannotatorBinary({ env: { PATH: "/one" }, diff --git a/packages/shared/plugin-binary.ts b/packages/shared/plugin-binary.ts index 12e1c9873..f1ab459bf 100644 --- a/packages/shared/plugin-binary.ts +++ b/packages/shared/plugin-binary.ts @@ -45,6 +45,12 @@ export interface PluginBinaryCompatibilityOptions { requiredFeatures?: readonly PluginFeature[]; } +const SOURCE_RUNTIME_ASSETS = [ + path.join("apps", "hook", "dist", "index.html"), + path.join("apps", "hook", "dist", "review.html"), + path.join("apps", "debug-frontend", "dist", "index.html"), +] as const; + function executableNames(platform: NodeJS.Platform): string[] { return platform === "win32" ? ["plannotator.exe", "plannotator.cmd", "plannotator.bat", "plannotator"] @@ -90,6 +96,20 @@ export function findPlannotatorSourceRoot( return undefined; } +export function isRunnablePlannotatorSourceRoot( + sourceRoot: string, + platform: NodeJS.Platform = process.platform, + exists: (candidate: string) => boolean = existsSync, +): boolean { + const sourceEntry = path.join(sourceRoot, "apps", "hook", "server", "index.ts"); + const sourceShim = path.join(sourceRoot, "bin", platform === "win32" ? "plannotator.cmd" : "plannotator.js"); + return ( + exists(sourceEntry) && + exists(sourceShim) && + SOURCE_RUNTIME_ASSETS.every((asset) => exists(path.join(sourceRoot, asset))) + ); +} + export function discoverPlannotatorBinary( options: PluginBinaryDiscoveryOptions = {}, ): PluginBinaryDiscoveryResult { @@ -128,11 +148,9 @@ export function discoverPlannotatorBinaryCandidates( addIfExists(explicit, "env"); } - if (options.sourceRoot) { - addIfExists( - path.join(options.sourceRoot, "bin", platform === "win32" ? "plannotator.cmd" : "plannotator.js"), - "source", - ); + if (options.sourceRoot && isRunnablePlannotatorSourceRoot(options.sourceRoot, platform, exists)) { + const sourceShim = path.join(options.sourceRoot, "bin", platform === "win32" ? "plannotator.cmd" : "plannotator.js"); + addIfExists(sourceShim, "source"); } const pathDirs = (env.PATH || "") diff --git a/packages/shared/plugin-client.test.ts b/packages/shared/plugin-client.test.ts index 1ea82f86a..688e727d4 100644 --- a/packages/shared/plugin-client.test.ts +++ b/packages/shared/plugin-client.test.ts @@ -42,6 +42,23 @@ describe("unsafeWindowsShellInvocationError", () => { ).toContain("opencode!calc"); }); + test("rejects grouping and quote metacharacters in Windows command wrapper arguments", () => { + expect( + unsafeWindowsShellInvocationError( + "C:\\Tools\\plannotator.cmd", + ["plugin", "plan", "--origin", "opencode)"], + "win32", + ), + ).toContain("opencode)"); + expect( + unsafeWindowsShellInvocationError( + "C:\\Tools\\plannotator.cmd", + ["plugin", "plan", "--origin", 'opencode"'], + "win32", + ), + ).toContain('opencode"'); + }); + test("does not apply shell-wrapper checks on non-Windows platforms", () => { expect( unsafeWindowsShellInvocationError( diff --git a/packages/shared/plugin-client.ts b/packages/shared/plugin-client.ts index f807dd556..a8f1f1850 100644 --- a/packages/shared/plugin-client.ts +++ b/packages/shared/plugin-client.ts @@ -88,7 +88,7 @@ function hasTimeout(timeoutMs: number | null | undefined): timeoutMs is number { } function hasWindowsShellMetachar(value: string): boolean { - return /[&|<>^%!]/.test(value); + return /[&|<>^%!()"]/.test(value); } function shouldUseShell(command: string, platform: NodeJS.Platform = process.platform): boolean { @@ -399,6 +399,8 @@ export function runPluginAnnotate( run: PluginCommandRunner = defaultPluginRunner, options: CommandRunOptions = {}, ): Promise> { + // annotate-last is part of the JSON request mode; the binary intentionally + // shares the same plugin annotate subcommand for all annotation flows. return runPluginCommand(binaryPath, "annotate", request, run, options); } diff --git a/packages/shared/plugin-protocol.ts b/packages/shared/plugin-protocol.ts index 1007b40d1..2f43401bd 100644 --- a/packages/shared/plugin-protocol.ts +++ b/packages/shared/plugin-protocol.ts @@ -16,7 +16,7 @@ export const PLANNOTATOR_PLUGIN_FEATURES = [ export type PluginFeature = (typeof PLANNOTATOR_PLUGIN_FEATURES)[number]; export type PluginClientOrigin = Extract; export type PluginRequestOrigin = Origin; -export type PluginSessionMode = "plan" | "review" | "annotate" | "archive" | "goal-setup"; +export type PluginSessionMode = "plan" | "review" | "annotate" | "archive"; export interface PluginCapabilities { protocol: typeof PLANNOTATOR_PLUGIN_PROTOCOL; @@ -80,19 +80,12 @@ export interface PluginArchiveRequest extends PluginBaseRequest { customPlanPath?: string | null; } -export interface PluginGoalSetupRequest extends PluginBaseRequest { - bundle: unknown; - stage: "interview" | "facts"; - goalSlug?: string; -} - export type PluginRequest = | ({ action: "plan" } & PluginPlanRequest) | ({ action: "review" } & PluginReviewRequest) | ({ action: "annotate" } & PluginAnnotateRequest) | ({ action: "annotate-last" } & PluginAnnotateRequest) - | ({ action: "archive" } & PluginArchiveRequest) - | ({ action: "goal-setup" } & PluginGoalSetupRequest); + | ({ action: "archive" } & PluginArchiveRequest); export interface PluginSessionInfo { mode: PluginSessionMode; diff --git a/scripts/dev-debug-stack.ts b/scripts/dev-debug-stack.ts new file mode 100644 index 000000000..6665a5556 --- /dev/null +++ b/scripts/dev-debug-stack.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env bun + +interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface DaemonStartOutput { + ok?: boolean; + browserUrl?: string; + status?: { + endpoint?: { + baseUrl?: string; + }; + }; +} + +const args = new Set(process.argv.slice(2)); +const skipBuild = args.has("--skip-build"); +const noBrowser = args.has("--no-browser"); +const noTui = args.has("--no-tui"); +const stopOnExit = args.has("--stop-on-exit"); +const reuseDaemon = args.has("--reuse-daemon"); + +if (!skipBuild) { + await runInherited("bun", ["run", "build:review"]); + await runInherited("bun", ["run", "build:hook"]); +} + +if (!reuseDaemon) { + await stopDaemonIfRunning(); +} + +const daemon = await startDaemon(); +const baseUrl = daemon.status?.endpoint?.baseUrl; +if (!baseUrl) { + throw new Error(`Daemon started but did not report a frontend URL: ${JSON.stringify(daemon)}`); +} +const browserUrl = daemon.browserUrl ?? baseUrl; + +console.error(`[plannotator] daemon frontend: ${baseUrl}`); + +if (!noBrowser) { + await openBrowser(browserUrl); +} + +try { + if (!noTui) { + await runInherited("bun", ["run", "--cwd", "apps/debug-tui", "start"], { + PLANNOTATOR_SIMULATOR_DAEMON_URL: baseUrl, + ...(daemon.browserUrl ? { PLANNOTATOR_SIMULATOR_DAEMON_BROWSER_URL: daemon.browserUrl } : {}), + }); + } else { + console.error( + stopOnExit + ? "[plannotator] --no-tui set; stopping daemon before exit." + : "[plannotator] --no-tui set; leaving daemon running.", + ); + } +} finally { + if (stopOnExit) { + await runCapture("bin/plannotator.js", ["daemon", "stop"]); + } +} + +async function startDaemon(): Promise { + const result = await runCapture("bin/plannotator.js", ["daemon", "start"]); + if (result.exitCode !== 0) { + throw new Error( + `Failed to start daemon.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + + const parsed = parseJson(result.stdout); + if (parsed?.ok && parsed.status?.endpoint?.baseUrl) return parsed; + + const status = await runCapture("bin/plannotator.js", ["daemon", "status"]); + if (status.exitCode !== 0) { + throw new Error(`Daemon status failed.\nstdout:\n${status.stdout}\nstderr:\n${status.stderr}`); + } + + const statusJson = parseJson<{ ok?: boolean; status?: DaemonStartOutput["status"]; browserUrl?: string }>( + status.stdout, + ); + if (!statusJson?.ok || !statusJson.status?.endpoint?.baseUrl) { + throw new Error(`Daemon status did not include a frontend URL: ${status.stdout}`); + } + + return { ok: true, status: statusJson.status, browserUrl: statusJson.browserUrl }; +} + +async function stopDaemonIfRunning(): Promise { + const result = await runCapture("bin/plannotator.js", ["daemon", "stop"]); + const parsed = parseJson>(result.stdout); + if (result.exitCode !== 0 && parsed?.code !== "missing") { + throw new Error(`Failed to stop existing daemon.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } +} + +async function openBrowser(url: string): Promise { + const opener = + process.platform === "darwin" + ? { command: "open", args: [url] } + : process.platform === "win32" + ? { command: "cmd", args: ["/c", "start", "", url] } + : { command: "xdg-open", args: [url] }; + + const result = await runCapture(opener.command, opener.args); + if (result.exitCode !== 0) { + console.error(`[plannotator] could not open browser automatically: ${result.stderr.trim()}`); + console.error(`[plannotator] open manually: ${url}`); + } +} + +async function runInherited( + command: string, + commandArgs: string[], + env?: Record, +): Promise { + const child = Bun.spawn([command, ...commandArgs], { + cwd: process.cwd(), + env: { ...process.env, ...env }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`${command} ${commandArgs.join(" ")} exited with ${exitCode}`); + } +} + +async function runCapture(command: string, commandArgs: string[]): Promise { + const child = Bun.spawn([command, ...commandArgs], { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stdout, stderr }; +} + +function parseJson(raw: string): T | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as T; + } catch { + return null; + } +}