From 030d06718d6f5632d4efc51d93d5a34a7fbe00a2 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 18:31:43 +0000 Subject: [PATCH 01/17] Add an Atomic SDK harness for the hosted Planner HARNESS=atomic embeds Atomic 0.9.23 in the server process through its headless SDK (createAgentSession, ModelRuntime, DefaultResourceLoader, and in-memory session and settings managers). It does not spawn the atomic CLI, use RPC mode, or alias Pi's runtime. Each harness session owns one Atomic AgentSession in a private temporary directory. Every shipped Atomic package is off, the tool allowlist holds only the host tools and a result tool, and the loader discovers no extensions, skills, prompt templates, or context files. A per-turn hook replaces the whole system prompt, so the model never sees Atomic's coding-agent preamble. The adapter fails the turn before any model request if the live session reports an extra extension, tool, context file, skill, prompt template, or system prompt. It checks the tools offered to the model again before every model request. Host tools round-trip through submitToolResult and settle on abort. Structured output uses a terminating result tool enabled only from the turn's JSON response format, which captures the calling model's arguments without a second inference. An unrecognized provider/model fails the turn instead of letting Atomic pick another model, and MODEL is now required for HARNESS=atomic as it is for Pi. HARNESS_AUTH is required and is either auto or ai-gateway. auto copies the host's Atomic login into memory and is refused off loopback; ai-gateway accepts only vercel-ai-gateway models. Credentials are never written to disk. The Pi auth checks now share the same table with unchanged messages. Docs cover selection, trust, credentials, and Atomic's caveats. Assistant-workflow: ralph (run 0cfa5220-f488-43ef-8bb5-2f54cf5141f2) Assistant-model: Claude Opus 5.5 Assistant-duration: 30m converged, estimated 65m Assistant-verification: bun test passed: 1683 pass, 2 PostgreSQL skips, 0 fail, including the atomic contract suite and the unchanged copilot-sdk and pi suites Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run ci passed: dprint, oxlint with no errors, tokens Assistant-verification: bun run fix passed: only formatting changes, inspected Assistant-verification: mutation checks passed: turning builtins back on, enabling skill discovery, or offering the result tool on plain turns each fail the atomic suite Assistant-verification: agent-browser E2E passed: a real Chopin server with HARNESS=atomic and a local stub model ran an @chopin turn that called read_plan and streamed the reply into Chat; the model was offered exactly PLANNER_TOOL_NAMES and Chopin's Planner prompt Assistant-verification: qlty smells passed: no blocking findings; complexity warnings match the Copilot adapter's closure shape User-preference: Build the Atomic harness on Atomic's headless SDK, which separates runtime from hosts Co-authored-by: Alex Lavaee --- .env.example | 6 +- README.md | 7 +- apps/server/package.json | 1 + apps/server/src/config.test.ts | 13 + apps/server/src/config.ts | 9 +- .../src/harness/atomic.contract.test.ts | 489 ++++++++++++++ .../server/src/harness/atomic/adapter.test.ts | 165 +++++ apps/server/src/harness/atomic/adapter.ts | 618 ++++++++++++++++++ apps/server/src/harness/harnesses.test.ts | 24 +- apps/server/src/harness/harnesses.ts | 32 +- bun.lock | 534 ++++++++++++++- docs/architecture.md | 6 +- docs/hosted-agent.md | 33 +- docs/self-hosting.md | 134 +++- package.json | 1 + 15 files changed, 1982 insertions(+), 90 deletions(-) create mode 100644 apps/server/src/harness/atomic.contract.test.ts create mode 100644 apps/server/src/harness/atomic/adapter.test.ts create mode 100644 apps/server/src/harness/atomic/adapter.ts diff --git a/.env.example b/.env.example index 157b61ea..845f169c 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,9 @@ DATABASE_URL=postgresql://chopin:chopin@127.0.0.1:5432/chopin?sslmode=disable AGENT=on # With HARNESS=pi, use an ID from Pi's catalog, preferably provider-qualified # (for example github-copilot/gpt-5.6-luna); Pi silently substitutes its default -# model for an ID it does not know, including gpt-6-luna. +# model for an ID it does not know, including gpt-6-luna. HARNESS=atomic requires +# a provider/model ID from Atomic's catalog (for example +# vercel-ai-gateway/anthropic/claude-sonnet-4.6) and fails a turn on any other. MODEL=gpt-6-luna HARNESS=copilot-sdk # Auth mode forwarded to the selected harness. The current copilot-sdk adapter @@ -14,6 +16,8 @@ HARNESS=copilot-sdk # such as "auto" require SERVER_HOST to bind only to loopback. HARNESS=pi # requires this to be set explicitly (auto, openai, anthropic, custom, or # ai-gateway); only ai-gateway is allowed on a non-loopback SERVER_HOST. +# HARNESS=atomic requires auto (the host's Atomic login and environment keys, +# loopback only) or ai-gateway (AI_GATEWAY_API_KEY, any bind). #HARNESS_AUTH= BACKGROUND_JOBS=on WEB_RESEARCH=on diff --git a/README.md b/README.md index f1542666..50f436a6 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,15 @@ and tool vocabulary remain optimized for planning. - The first eligible person to invoke the Planner or start a model-backed research request supplies the GitHub App user token used for that channel and, under the default `copilot-sdk` harness, the Copilot entitlement. With - `HARNESS=pi`, model access comes from the operator's `HARNESS_AUTH` instead. + `HARNESS=pi` or `HARNESS=atomic`, model access comes from the operator's + `HARNESS_AUTH` instead. A server restart clears every session and releases that ownership. A returning browser in local device mode can restore a new session, but does not reclaim Planner ownership. - Document and Chat context, along with repository material selected by the Planner, is sent to the harness's model provider during a turn (GitHub - Copilot by default; under `HARNESS=pi`, the provider chosen by - `HARNESS_AUTH`). Model-backed background jobs also send job-specific private + Copilot by default; under `HARNESS=pi` or `HARNESS=atomic`, the provider + chosen by `HARNESS_AUTH`). Model-backed background jobs also send job-specific private material, including context loaded during execution, to isolated workers on the same harness. The public research worker receives only the exact submitted brief, but may derive or refine the queries it sends diff --git a/apps/server/package.json b/apps/server/package.json index d5bf7113..9ff841f0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,7 @@ "@ai-sdk/harness-pi": "catalog:harness", "@ai-sdk/mcp": "catalog:harness", "@ai-sdk/sandbox-just-bash": "catalog:harness", + "@bastani/atomic": "catalog:harness", "@earendil-works/pi-coding-agent": "catalog:harness", "@chopin/dialect": "workspace:*", "@chopin/protocol": "workspace:*", diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 23e66c49..396c1634 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -113,6 +113,19 @@ describe("configuration", () => { .toMatchObject({ harness: "pi", model: "openai/gpt-5.6" }); }); + it("requires an explicit provider/model MODEL under the atomic harness", () => { + expect(() => configured({ HARNESS: "atomic", HARNESS_AUTH: "auto" })) + .toThrow("MODEL is required when HARNESS=atomic"); + expect(configured({ + HARNESS: "atomic", + HARNESS_AUTH: "ai-gateway", + MODEL: "vercel-ai-gateway/anthropic/claude-sonnet-4.6", + })).toMatchObject({ + harness: "atomic", + model: "vercel-ai-gateway/anthropic/claude-sonnet-4.6", + }); + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e5b13483..d4b3da51 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -48,12 +48,15 @@ export type Config = { const DEFAULT_PORT = 8787; const DEFAULT_MODEL = "gpt-6-luna"; -/** The default is a Copilot model ID. Pi resolves IDs against its own catalog, - * so a Pi deployment must name its model rather than inherit one Pi lacks. */ +/** The default is a Copilot model ID. Pi and Atomic resolve IDs against their + * own catalogs, so those deployments must name a model rather than inherit one + * the catalog lacks. */ function model(harness: string): string { let configured = process.env.MODEL; if (configured) return configured; - if (harness === "pi") throw new Error("MODEL is required when HARNESS=pi"); + if (harness === "pi" || harness === "atomic") { + throw new Error(`MODEL is required when HARNESS=${harness}`); + } return DEFAULT_MODEL; } export function harnessSelection(): Pick { diff --git a/apps/server/src/harness/atomic.contract.test.ts b/apps/server/src/harness/atomic.contract.test.ts new file mode 100644 index 00000000..202297d0 --- /dev/null +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -0,0 +1,489 @@ +import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; +import { Output, tool } from "ai"; +import { afterAll, describe, expect, it } from "bun:test"; +import { z } from "zod"; +import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + ATOMIC_DEFAULT_SYSTEM_PROMPT, + ATOMIC_RESULT_INSTRUCTION, + ATOMIC_RESULT_TOOL_NAME, + createAtomicAdapter, +} from "./atomic/adapter"; +import { harnessContract } from "./contract"; +import { startStubModelServer } from "./pi/model-stub"; + +import type { HarnessV1StartOptions, HarnessV1StreamPart } from "@ai-sdk/harness"; +import type { ToolSet } from "ai"; +import type { AtomicSettings } from "./atomic/adapter"; +import type { StubTurn } from "./pi/model-stub"; + +let stub = startStubModelServer((prompt, hasPriorToolResult): StubTurn => { + if (prompt === "again") { + return { kind: "tool", name: ATOMIC_RESULT_TOOL_NAME, arguments: '{"answer":"again"}' }; + } + if (hasPriorToolResult) return { kind: "text", text: "Done." }; + if (prompt === "tools") return { kind: "tool", name: "first_host", arguments: "{}" }; + if (prompt === "output" || prompt === "rogue") { + return { kind: "tool", name: ATOMIC_RESULT_TOOL_NAME, arguments: '{"answer":"yes"}' }; + } + if (prompt === "abort") return { kind: "hold" }; + if (prompt === "bash") return { kind: "tool", name: "bash", arguments: '{"command":"id"}' }; + return { kind: "text", text: "The Atomic stub model answered." }; +}); +afterAll(stub.stop); + +const STUB_MODEL = { + id: "stub-model", + name: "Stub Model", + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, +}; + +/** `ai-gateway` never reads the host login; the stub is a provider registered by code. */ +function createStubAtomic(overrides: Partial = {}) { + return createAtomicAdapter({ + auth: "ai-gateway", + model: "stub/stub-model", + providers: { + stub: { + baseUrl: stub.baseUrl, + apiKey: "stub-key", + api: "openai-completions", + models: [STUB_MODEL], + }, + }, + ...overrides, + }); +} + +harnessContract("atomic", createStubAtomic, createJustBashNetworkSandboxSession); + +async function run(prompt: string, options: { + structured?: boolean; + instructions?: string; + tools?: ToolSet; + model?: string; + harness?: ReturnType; +} = {}) { + let tools = options.tools ?? {}; + let agent = new HarnessAgent({ + harness: options.harness ?? createStubAtomic(), + tools, + activeTools: Object.keys(tools), + permissionMode: "allow-reads", + ...(options.instructions ? { instructions: options.instructions } : {}), + prepareCall: call => ({ ...call, model: options.model ?? call.model }), + ...(options.structured + ? { output: Output.object({ schema: z.object({ answer: z.string() }) }) } + : {}), + }); + let session = await agent.createSession({ + sandboxSession: await createJustBashNetworkSandboxSession(), + }); + try { + let result = await agent.stream({ session, prompt }); + let parts: { type: string; toolName?: string; output?: unknown }[] = []; + for await (let part of result.fullStream) { + parts.push({ + type: part.type, + ...("toolName" in part ? { toolName: part.toolName } : {}), + ...(part.type === "tool-result" ? { output: part.output } : {}), + }); + } + return { + output: options.structured ? await result.output : undefined, + text: await result.text, + parts, + resultToolParts: parts.filter(part => part.toolName === ATOMIC_RESULT_TOOL_NAME).length, + }; + } finally { + await session.destroy(); + } +} + +describe("atomic turns", () => { + it("answers a plain text turn with exactly the neutral system prompt and no tools", async () => { + stub.requests.length = 0; + let result = await run("plain"); + expect(result.text).toBe("The Atomic stub model answered."); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]!.system).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); + expect(stub.requests[0]!.toolNames).toEqual([]); + }); + + it("uses the turn instructions verbatim as the whole system prompt", async () => { + stub.requests.length = 0; + await run("plain", { instructions: "You are Chopin's Planner." }); + expect(stub.requests[0]!.system).toBe("You are Chopin's Planner."); + }); + + it("round-trips a host tool through submitToolResult", async () => { + stub.requests.length = 0; + let executed = 0; + let result = await run("tools", { + tools: { + first_host: tool({ + inputSchema: z.object({}), + execute: async () => { + executed++; + return { found: true }; + }, + }), + }, + }); + expect(executed).toBe(1); + expect(result.text).toBe("Done."); + expect(stub.requests.map(request => request.hasPriorToolResult)).toEqual([false, true]); + expect(stub.requests[0]!.toolNames).toEqual(["first_host"]); + expect(result.parts.filter(part => part.toolName === "first_host")).toEqual([ + { type: "tool-call", toolName: "first_host" }, + { type: "tool-result", toolName: "first_host", output: { found: true } }, + ]); + }); + + it("refuses a hallucinated Atomic built-in without running it or streaming it", async () => { + stub.requests.length = 0; + let result = await run("bash"); + expect(result.text).toBe("Done."); + expect(result.parts.filter(part => part.toolName)).toEqual([]); + expect(stub.requests.map(request => request.hasPriorToolResult)).toEqual([false, true]); + }); + + it("keeps one Atomic conversation across structured turns on a session", async () => { + stub.requests.length = 0; + let agent = new HarnessAgent({ + harness: createStubAtomic(), + activeTools: [], + output: Output.object({ schema: z.object({ answer: z.string() }) }), + }); + let session = await agent.createSession({ + sandboxSession: await createJustBashNetworkSandboxSession(), + }); + try { + expect((await agent.generate({ session, prompt: "output" })).output).toEqual({ + answer: "yes", + }); + expect((await agent.generate({ session, prompt: "again" })).output).toEqual({ + answer: "again", + }); + } finally { + await session.destroy(); + } + expect(stub.requests.map(request => [request.prompt, request.hasPriorToolResult])).toEqual([ + ["output", false], + ["again", true], + ]); + }); +}); + +describe("atomic HarnessV1 lifecycle", () => { + const HOST_TOOL = { name: "first_host", inputSchema: { type: "object" as const } }; + + function recorder() { + let parts: HarnessV1StreamPart[] = []; + let called = Promise.withResolvers(); + return { + parts, + called: called.promise, + emit(part: HarnessV1StreamPart) { + parts.push(part); + if (part.type === "tool-call") called.resolve(part.toolCallId); + }, + }; + } + + async function start() { + return createStubAtomic().doStart({ + sessionId: crypto.randomUUID(), + sandboxSession: {} as HarnessV1StartOptions["sandboxSession"], + sessionWorkDir: "/workspace", + }); + } + + it("settles a pending host tool on abort and ignores a late result", async () => { + let session = await start(); + let first = recorder(); + let controller = new AbortController(); + try { + let control = await session.doPromptTurn({ + prompt: "tools", + skills: [], + tools: [HOST_TOOL], + abortSignal: controller.signal, + emit: first.emit, + }); + let toolCallId = await first.called; + controller.abort(); + await control.done; + await control.submitToolResult({ toolCallId, output: "late" }); + expect(first.parts.map(part => part.type)).not.toContain("tool-result"); + expect(first.parts.map(part => part.type)).not.toContain("finish"); + } finally { + await session.doDestroy(); + } + }); + + it("continues a live turn on a new stream and finishes it there", async () => { + let session = await start(); + let first = recorder(); + let second = recorder(); + try { + await session.doPromptTurn({ + prompt: "tools", + skills: [], + tools: [HOST_TOOL], + emit: first.emit, + }); + let toolCallId = await first.called; + let control = await session.doContinueTurn({ + skills: [], + tools: [HOST_TOOL], + emit: second.emit, + }); + await control.submitToolResult({ toolCallId, output: "ok" }); + await control.done; + expect(second.parts.map(part => part.type)).toEqual([ + "tool-result", + "text-start", + "text-delta", + "text-end", + "finish-step", + "finish", + ]); + await expect( + session.doContinueTurn({ skills: [], tools: [HOST_TOOL], emit: second.emit }), + ).rejects.toBeInstanceOf(HarnessCapabilityUnsupportedError); + } finally { + await session.doDestroy(); + } + }); + + it("rebuilds the Atomic session for a new tool set without losing the conversation", async () => { + stub.requests.length = 0; + let session = await start(); + let first = recorder(); + try { + let control = await session.doPromptTurn({ + prompt: "tools", + skills: [], + tools: [HOST_TOOL], + emit: first.emit, + }); + await control.submitToolResult({ toolCallId: await first.called, output: "ok" }); + await control.done; + let next = await session.doPromptTurn({ + prompt: "plain", + skills: [], + tools: [{ name: "second_host", inputSchema: { type: "object" } }], + emit: () => {}, + }); + await next.done; + } finally { + await session.doDestroy(); + } + expect(stub.requests.map(request => [request.prompt, request.toolNames])).toEqual([ + ["tools", ["first_host"]], + ["tools", ["first_host"]], + ["plain", ["second_host"]], + ]); + expect(stub.requests.at(-1)!.hasPriorToolResult).toBe(true); + }); + + it("stops into a resume state it will not pretend to restore", async () => { + let session = await start(); + let state = await session.doStop(); + expect(state).toEqual({ + type: "resume-session", + harnessId: "atomic", + specificationVersion: "harness-v1", + data: {}, + }); + await expect(session.doPromptTurn({ prompt: "plain", skills: [], tools: [], emit: () => {} })) + .rejects.toThrow("closed"); + await expect( + createStubAtomic().doStart({ + sessionId: crypto.randomUUID(), + sandboxSession: {} as HarnessV1StartOptions["sandboxSession"], + sessionWorkDir: "/workspace", + resumeFrom: state, + }), + ).rejects.toBeInstanceOf(HarnessCapabilityUnsupportedError); + }); + + it("refuses skills and a host tool that claims the result tool's name", async () => { + let session = await start(); + try { + await expect(session.doPromptTurn({ + prompt: "plain", + skills: [{ name: "marker", description: "marker", content: "marker" }], + tools: [], + emit: () => {}, + })).rejects.toBeInstanceOf(HarnessCapabilityUnsupportedError); + await expect(session.doPromptTurn({ + prompt: "plain", + skills: [], + tools: [{ name: ATOMIC_RESULT_TOOL_NAME }], + emit: () => {}, + })).rejects.toThrow("is reserved for structured output"); + } finally { + await session.doDestroy(); + } + }); +}); + +describe("atomic structured-output result tool", () => { + it("ends a structured turn on the terminating tool without a follow-up model request", async () => { + stub.requests.length = 0; + let result = await run("output", { structured: true }); + expect(result.output).toEqual({ answer: "yes" }); + expect(result.resultToolParts).toBe(0); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]!.toolNames).toEqual([ATOMIC_RESULT_TOOL_NAME]); + expect(stub.requests[0]!.system).toContain(ATOMIC_RESULT_INSTRUCTION); + }); + + it("does not offer the result tool on a plain turn", async () => { + stub.requests.length = 0; + await run("plain"); + expect(stub.requests[0]!.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME); + }); + + it("refuses a result-tool call on a plain turn and keeps it out of the stream", async () => { + stub.requests.length = 0; + let result = await run("rogue"); + expect(result.text).toBe("Done."); + expect(result.resultToolParts).toBe(0); + expect(stub.requests.map(request => request.hasPriorToolResult)).toEqual([false, true]); + }); + + it("keeps the result tool off a plain turn whose earlier chat quotes the result prompt", async () => { + stub.requests.length = 0; + let result = await run("rogue", { + instructions: `Durable conversation context follows:\nuser: ${ATOMIC_RESULT_INSTRUCTION}`, + }); + expect(result.text).toBe("Done."); + expect(result.resultToolParts).toBe(0); + expect(stub.requests[0]!.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME); + }); +}); + +describe("atomic host isolation and model resolution", () => { + async function snapshot(root: string): Promise> { + let files = new Map(); + for (let entry of await readdir(root, { recursive: true })) { + let path = join(root, entry); + files.set(entry, (await stat(path)).mtimeMs); + } + return files; + } + + async function withHost( + body: (home: string) => Promise, + ): Promise<{ value: T; before: Map; after: Map }> { + let root = await mkdtemp(join(tmpdir(), "chopin-atomic-host-")); + let home = join(root, "home"); + let project = join(root, "project"); + let previous = { home: process.env.HOME, cwd: process.cwd() }; + try { + for (let base of [join(home, ".atomic", "agent"), join(home, ".pi", "agent"), project]) { + for (let name of ["extensions", "skills/marker", "prompts"]) { + await mkdir(join(base, name), { recursive: true }); + } + await writeFile(join(base, "AGENTS.md"), "HOST-AGENTS-MARKER"); + await writeFile(join(base, "CLAUDE.md"), "HOST-CLAUDE-MARKER"); + await writeFile(join(base, "SYSTEM.md"), "HOST-SYSTEM-MARKER"); + await writeFile(join(base, "APPEND_SYSTEM.md"), "HOST-APPEND-MARKER"); + await writeFile( + join(base, "extensions", "marker.ts"), + "export default (pi) => pi.on('before_agent_start', () => ({ systemPrompt: 'HOST-EXTENSION-MARKER' }));", + ); + await writeFile( + join(base, "skills", "marker", "SKILL.md"), + "---\nname: marker\ndescription: HOST-SKILL-MARKER\n---\nHOST-SKILL-MARKER", + ); + await writeFile(join(base, "prompts", "marker.md"), "HOST-PROMPT-MARKER"); + await writeFile( + join(base, "settings.json"), + JSON.stringify({ defaultTools: ["bash"], extensions: ["./extensions/marker.ts"] }), + ); + } + for (let dir of [".atomic", ".pi", ".agents"]) { + await mkdir(join(project, dir, "extensions"), { recursive: true }); + await writeFile( + join(project, dir, "extensions", "marker.ts"), + "export default (pi) => pi.on('before_agent_start', () => ({ systemPrompt: 'PROJECT-EXTENSION-MARKER' }));", + ); + } + await writeFile( + join(home, ".atomic", "agent", "auth.json"), + JSON.stringify({ stub: { type: "api_key", key: "host-login-key" } }), + ); + process.env.HOME = home; + process.chdir(project); + let before = await snapshot(root); + let value = await body(home); + return { value, before, after: await snapshot(root) }; + } finally { + process.chdir(previous.cwd); + if (previous.home === undefined) delete process.env.HOME; + else process.env.HOME = previous.home; + await rm(root, { recursive: true, force: true }); + } + } + + it("keeps host context files, skills, prompts, extensions, and settings out of the turn", async () => { + stub.requests.length = 0; + let { value, before, after } = await withHost(() => run("plain")); + expect(value.text).toBe("The Atomic stub model answered."); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]!.system).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); + expect(stub.requests[0]!.toolNames).toEqual([]); + expect(after).toEqual(before); + }); + + it("uses the host login in auto mode without writing credentials to disk", async () => { + stub.requests.length = 0; + let { value, before, after } = await withHost(async home => { + let harness = createAtomicAdapter({ + auth: "auto", + model: "stub/stub-model", + providers: { + stub: { baseUrl: stub.baseUrl, api: "openai-completions", models: [STUB_MODEL] }, + }, + }); + let text = (await run("plain", { harness })).text; + let auth = await readFile(join(home, ".atomic", "agent", "auth.json"), "utf8"); + return { text, auth }; + }); + expect(value.text).toBe("The Atomic stub model answered."); + expect(JSON.parse(value.auth)).toEqual({ stub: { type: "api_key", key: "host-login-key" } }); + expect(after).toEqual(before); + }); + + it("fails the turn when Atomic does not recognize the requested model", async () => { + stub.requests.length = 0; + await expect(run("plain", { model: "stub/not-a-model" })).rejects.toThrow( + "Atomic does not recognize model stub/not-a-model", + ); + await expect(run("plain", { model: "gpt-6-luna" })).rejects.toThrow( + "Atomic does not recognize model gpt-6-luna", + ); + expect(stub.requests).toHaveLength(0); + }); + + it("refuses non-gateway models under the ai-gateway auth mode", async () => { + stub.requests.length = 0; + await expect(run("plain", { model: "anthropic/claude-sonnet-4-5" })).rejects.toThrow( + "HARNESS_AUTH ai-gateway allows only vercel-ai-gateway models", + ); + expect(stub.requests).toHaveLength(0); + }); +}); diff --git a/apps/server/src/harness/atomic/adapter.test.ts b/apps/server/src/harness/atomic/adapter.test.ts new file mode 100644 index 00000000..0e4fd3a5 --- /dev/null +++ b/apps/server/src/harness/atomic/adapter.test.ts @@ -0,0 +1,165 @@ +import { expect, test } from "bun:test"; + +import { + ATOMIC_DEFAULT_SYSTEM_PROMPT, + ATOMIC_RESULT_INSTRUCTION, + ATOMIC_RESULT_TOOL_NAME, + atomicTurnExtension, + createAtomicAdapter, + hostLeak, + resolveModel, + turnSystemPrompt, +} from "./adapter"; + +import type { ExtensionAPI } from "@bastani/atomic"; +import type { AtomicAuthMode, TurnPolicy } from "./adapter"; + +type Handler = (event: Record) => unknown; + +function fakeExtensionApi(policy: TurnPolicy) { + let tools: { name: string; execute: (...args: unknown[]) => Promise }[] = []; + let handlers = new Map(); + let active: string[] = []; + let api = { + registerTool(tool: { name: string; execute: (...args: unknown[]) => Promise }) { + tools.push(tool); + }, + on(event: string, handler: Handler) { + handlers.set(event, handler); + }, + setActiveTools(names: string[]) { + active = [...names]; + }, + }; + atomicTurnExtension(policy)(api as unknown as ExtensionAPI); + return { + tools, + get active() { + return active; + }, + start: () => handlers.get("before_agent_start")!({ type: "before_agent_start" }), + call: (toolName: string) => handlers.get("tool_call")!({ type: "tool_call", toolName }), + }; +} + +test("registers one result tool that terminates the turn and records its arguments", async () => { + let policy: TurnPolicy = { hostNames: [], structured: true, systemPrompt: "System." }; + let atomic = fakeExtensionApi(policy); + expect(atomic.tools.map(tool => tool.name)).toEqual([ATOMIC_RESULT_TOOL_NAME]); + let result = await atomic.tools[0]!.execute("call-1", { answer: "yes" }); + expect(result).toMatchObject({ details: { answer: "yes" }, terminate: true }); + expect(policy.result).toEqual({ value: { answer: "yes" } }); +}); + +test("applies the turn's tools and exact system prompt when the agent starts", () => { + let policy: TurnPolicy = { hostNames: ["first_host"], structured: true, systemPrompt: "Plan." }; + let atomic = fakeExtensionApi(policy); + expect(atomic.start()).toEqual({ systemPrompt: "Plan." }); + expect(atomic.active).toEqual(["first_host", ATOMIC_RESULT_TOOL_NAME]); + + policy.structured = false; + policy.systemPrompt = `Plan.\n\nuser: ${ATOMIC_RESULT_INSTRUCTION}`; + expect(atomic.start()).toEqual({ systemPrompt: policy.systemPrompt }); + expect(atomic.active).toEqual(["first_host"]); +}); + +test("blocks the result tool off structured turns and anything outside the turn", () => { + let policy: TurnPolicy = { hostNames: ["first_host"], structured: false, systemPrompt: "S." }; + let atomic = fakeExtensionApi(policy); + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toMatchObject({ block: true }); + expect(atomic.call("bash")).toMatchObject({ block: true }); + expect(atomic.call("first_host")).toBeUndefined(); + policy.structured = true; + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toBeUndefined(); +}); + +test("builds the system prompt from instructions and the structured response format", () => { + expect(turnSystemPrompt(undefined, undefined)).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); + expect(turnSystemPrompt("", { type: "text" })).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); + expect(turnSystemPrompt("Summarize.", undefined)).toBe("Summarize."); + let schema = { type: "object", properties: { answer: { type: "string" } } }; + let prompt = turnSystemPrompt("Summarize.", { + type: "json", + schema, + description: "One answer.", + }); + expect(prompt).toStartWith("Summarize.\n\n"); + expect(prompt).toContain(ATOMIC_RESULT_INSTRUCTION); + expect(prompt).toContain("One answer."); + expect(prompt).toContain(JSON.stringify(schema)); + expect(turnSystemPrompt(undefined, { type: "json" })).toStartWith(ATOMIC_RESULT_INSTRUCTION); +}); + +test("resolves provider/model exactly and never substitutes a default", () => { + let known = new Map([ + ["stub/stub-model", { provider: "stub", id: "stub-model" }], + ["anthropic/claude", { provider: "anthropic", id: "claude" }], + ["vercel-ai-gateway/anthropic/claude", { + provider: "vercel-ai-gateway", + id: "anthropic/claude", + }], + ]); + let runtime = { + getModel: (provider: string, id: string) => known.get(`${provider}/${id}`) as never, + }; + let auto = { auth: "auto" as AtomicAuthMode }; + let gateway = { auth: "ai-gateway" as AtomicAuthMode, providers: { stub: {} as never } }; + + expect(resolveModel(runtime, auto, "anthropic/claude")).toMatchObject({ id: "claude" }); + expect(resolveModel(runtime, gateway, "vercel-ai-gateway/anthropic/claude")) + .toMatchObject({ id: "anthropic/claude" }); + expect(resolveModel(runtime, gateway, "stub/stub-model")).toMatchObject({ id: "stub-model" }); + expect(() => resolveModel(runtime, auto, undefined)).toThrow("needs a model"); + for (let name of ["gpt-6-luna", "/claude", "stub/other", "anthropic/"]) { + expect(() => resolveModel(runtime, auto, name)) + .toThrow(`Atomic does not recognize model ${name}`); + } + expect(() => resolveModel(runtime, gateway, "anthropic/claude")) + .toThrow("HARNESS_AUTH ai-gateway allows only vercel-ai-gateway models, not anthropic/claude"); +}); + +test("reports every way a built session exceeds the turn", () => { + function session(options: { + tools?: string[]; + agentsFiles?: number; + skills?: number; + prompts?: number; + append?: string[]; + systemPrompt?: string; + }) { + return { + getAllTools: () => + (options.tools ?? ["first_host", ATOMIC_RESULT_TOOL_NAME]).map(name => ({ name })), + resourceLoader: { + getAgentsFiles: () => ({ agentsFiles: Array(options.agentsFiles ?? 0) }), + getSkills: () => ({ skills: Array(options.skills ?? 0) }), + getPrompts: () => ({ prompts: Array(options.prompts ?? 0) }), + getAppendSystemPrompt: () => options.append ?? [], + getSystemPrompt: () => options.systemPrompt ?? ATOMIC_DEFAULT_SYSTEM_PROMPT, + }, + } as never; + } + expect(hostLeak(session({}), 1, ["first_host"])).toBeUndefined(); + expect(hostLeak(session({}), 6, ["first_host"])).toBe("6 extensions"); + expect(hostLeak(session({ tools: ["first_host", ATOMIC_RESULT_TOOL_NAME, "bash"] }), 1, [ + "first_host", + ])).toBe(`tools first_host, ${ATOMIC_RESULT_TOOL_NAME}, bash`); + expect(hostLeak( + session({ agentsFiles: 1, skills: 2, prompts: 1, append: ["x"], systemPrompt: "Host" }), + 1, + ["first_host"], + )).toBe( + "context files; skills; prompt templates; appended system prompts; a host system prompt", + ); +}); + +test("declares no built-ins and refuses an undefined auth mode", () => { + let harness = createAtomicAdapter({ auth: "ai-gateway" }); + expect(harness.harnessId).toBe("atomic"); + expect(harness.builtinTools).toEqual({}); + expect(harness.supportsBuiltinToolFiltering).toBe(true); + expect(() => createAtomicAdapter({ auth: "direct" as AtomicAuthMode })) + .toThrow("HARNESS_AUTH direct is not a supported atomic authentication mode"); + expect(() => createAtomicAdapter({ auth: undefined as unknown as AtomicAuthMode })) + .toThrow("is not a supported atomic authentication mode"); +}); diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts new file mode 100644 index 00000000..9d37dc9b --- /dev/null +++ b/apps/server/src/harness/atomic/adapter.ts @@ -0,0 +1,618 @@ +/** + * Chopin's Atomic `HarnessV1` adapter. + * + * Embeds Atomic in-process through its headless SDK. Each harness session owns + * one Atomic `AgentSession` with every shipped builtin package off, no Atomic + * coding tools, an inert resource loader, and in-memory session, settings, and + * credential stores. The model is offered only the host tools `HarnessAgent` + * passes for the turn, plus a terminating result tool on structured turns. The + * tool set is checked before every prompt and before every model request, and a + * mismatch fails the turn. + */ + +import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; +import { + AuthStorage, + createAgentSession, + DefaultResourceLoader, + FileAuthStorageBackend, + getAgentConfigPaths, + ModelRuntime, + SessionManager, + SettingsManager, +} from "@bastani/atomic"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { + HarnessV1, + HarnessV1PromptControl, + HarnessV1PromptTurnOptions, + HarnessV1ResponseFormat, + HarnessV1ResumeSessionState, + HarnessV1Session, + HarnessV1StartOptions, + HarnessV1StreamPart, + HarnessV1ToolSpec, +} from "@ai-sdk/harness"; +import type { + AgentSession, + AgentSessionEvent, + ExtensionAPI, + ExtensionFactory, + ToolDefinition, +} from "@bastani/atomic"; + +export const ATOMIC_AUTH_MODES = ["auto", "ai-gateway"] as const; + +export type AtomicAuthMode = (typeof ATOMIC_AUTH_MODES)[number]; + +export const ATOMIC_RESULT_TOOL_NAME = "chopin_submit_atomic_result"; + +export const ATOMIC_RESULT_INSTRUCTION = + `When you have the final answer for this turn, call the ${ATOMIC_RESULT_TOOL_NAME} tool exactly ` + + "once with the required fields instead of replying in plain text."; + +/** Replaces Atomic's coding-agent preamble on turns that bring no instructions. */ +export const ATOMIC_DEFAULT_SYSTEM_PROMPT = + "Respond to the user's message. Use only the tools this turn provides."; + +const GATEWAY_PROVIDER = "vercel-ai-gateway"; +const OPERATION_TIMEOUT_MS = 10_000; +const BUILTINS_OFF = { + workflows: false, + subagents: false, + mcp: false, + "web-access": false, + intercom: false, +}; +const SETTINGS = { + compaction: { enabled: false }, + sessionSummary: { enabled: false }, + cacheWarming: "off" as const, +}; +const ZERO_USAGE = { + inputTokens: { + total: undefined, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: undefined, text: undefined, reasoning: undefined }, +}; + +type ProviderConfig = Parameters[1]; +type Model = NonNullable>; +type ToolOutcome = { output: unknown; isError?: boolean }; + +export type AtomicSettings = { + auth: AtomicAuthMode; + /** Model, as `provider/model`, for turns that name none. */ + model?: string; + /** Providers registered by code rather than discovered from the host. */ + providers?: Record; +}; + +export class ToolBoundaryError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolBoundaryError"; + } +} + +/** What the turn extension enforces; the adapter rewrites it before each turn. */ +export type TurnPolicy = { + hostNames: string[]; + structured: boolean; + systemPrompt: string; + result?: { value: unknown }; +}; + +/** + * Registers the terminating result tool and applies the per-turn policy: the + * active tools and the exact system prompt. `structured` comes only from the + * turn's response format, never from prompt text, so quoted chat cannot switch + * the result tool on. + */ +export function atomicTurnExtension(policy: TurnPolicy): ExtensionFactory { + return (atomic: ExtensionAPI): void => { + atomic.registerTool({ + name: ATOMIC_RESULT_TOOL_NAME, + label: "Submit result", + description: "Submit the final structured result for this turn.", + parameters: { type: "object", additionalProperties: true } as ToolDefinition["parameters"], + maxResultSizeChars: Infinity, + async execute(_toolCallId, params) { + policy.result = { value: params }; + return { + content: [{ type: "text", text: "Result received." }], + details: params, + terminate: true, + }; + }, + }); + atomic.on("before_agent_start", () => { + atomic.setActiveTools(activeNames(policy)); + return { systemPrompt: policy.systemPrompt }; + }); + atomic.on("tool_call", event => { + if (activeNames(policy).includes(event.toolName)) return; + return { block: true, reason: `${event.toolName} is not available in this turn.` }; + }); + }; +} + +function activeNames(policy: TurnPolicy): string[] { + return policy.structured ? [...policy.hostNames, ATOMIC_RESULT_TOOL_NAME] : [...policy.hostNames]; +} + +export function turnSystemPrompt( + instructions: string | undefined, + responseFormat: HarnessV1ResponseFormat | undefined, +): string { + let result = responseFormat?.type === "json" + ? [ + ATOMIC_RESULT_INSTRUCTION, + responseFormat.description, + `The ${ATOMIC_RESULT_TOOL_NAME} arguments must match this JSON schema: ${ + JSON.stringify(responseFormat.schema ?? { type: "object" }) + }`, + ].filter(Boolean).join("\n") + : undefined; + return [instructions, result].filter(Boolean).join("\n\n") || ATOMIC_DEFAULT_SYSTEM_PROMPT; +} + +/** Looks up `provider/model` without letting Atomic substitute a default. */ +export function resolveModel( + runtime: Pick, + settings: Pick, + requested: string | undefined, +): Model { + if (!requested) throw new Error("Atomic needs a model named as provider/model."); + let slash = requested.indexOf("/"); + let provider = requested.slice(0, slash); + let model = slash > 0 ? runtime.getModel(provider, requested.slice(slash + 1)) : undefined; + if (!model) throw new Error(`Atomic does not recognize model ${requested}`); + if ( + settings.auth === "ai-gateway" && provider !== GATEWAY_PROVIDER + && !Object.hasOwn(settings.providers ?? {}, provider) + ) { + throw new Error( + `HARNESS_AUTH ai-gateway allows only ${GATEWAY_PROVIDER} models, not ${requested}`, + ); + } + return model; +} + +/** + * Credentials live only in memory. `auto` copies the host's Atomic login once, + * read without a lock file, so a token refresh can never be written back. + */ +async function createRuntime(settings: AtomicSettings): Promise { + let login = {}; + if (settings.auth === "auto") { + let paths = getAgentConfigPaths("auth.json"); + let content = new FileAuthStorageBackend(paths[0], paths).read(); + if (content) login = JSON.parse(content); + } + let runtime = await ModelRuntime.create({ + credentials: AuthStorage.inMemory(login), + modelsPath: null, + allowModelNetwork: false, + }); + for (let [id, config] of Object.entries(settings.providers ?? {})) { + runtime.registerProvider(id, config); + } + return runtime; +} + +function unsupported(capability: string): HarnessCapabilityUnsupportedError { + return new HarnessCapabilityUnsupportedError({ + harnessId: "atomic", + message: `Atomic adapter does not support ${capability}.`, + }); +} + +function bounded(operation: Promise, message: string): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), OPERATION_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timer)); +} + +function promptText(prompt: HarnessV1PromptTurnOptions["prompt"]): string { + if (typeof prompt === "string") return prompt; + if (typeof prompt.content === "string") return prompt.content; + return prompt.content.map(part => { + if (part.type !== "text") throw unsupported(`${part.type} prompt parts`); + return part.text; + }).join("\n\n"); +} + +function sameNames(actual: readonly string[], expected: readonly string[]): boolean { + let left = [...actual].sort(); + let right = [...expected].sort(); + return left.length === right.length && left.every((name, index) => name === right[index]); +} + +/** What a freshly built session exposes beyond the turn extension and the host tools, if anything. */ +export function hostLeak( + session: Pick, + extensions: number, + hostNames: readonly string[], +): string | undefined { + let resources = session.resourceLoader; + let tools = session.getAllTools().map(tool => tool.name); + let found = [ + extensions !== 1 && `${extensions} extensions`, + !sameNames(tools, [...hostNames, ATOMIC_RESULT_TOOL_NAME]) && `tools ${tools.join(", ")}`, + resources.getAgentsFiles().agentsFiles.length > 0 && "context files", + resources.getSkills().skills.length > 0 && "skills", + resources.getPrompts().prompts.length > 0 && "prompt templates", + resources.getAppendSystemPrompt().length > 0 && "appended system prompts", + resources.getSystemPrompt() !== ATOMIC_DEFAULT_SYSTEM_PROMPT && "a host system prompt", + ].filter(Boolean); + return found.length > 0 ? found.join("; ") : undefined; +} + +function serialize(output: unknown): string { + return typeof output === "string" ? output : JSON.stringify(output ?? null); +} + +type Run = { + emit: (part: HarnessV1StreamPart) => void; + pending: Map void>; + blocks: Map; + stopped: boolean; + failure?: Error; + last?: { stopReason: string; errorMessage?: string }; + stop: () => void; + done: Promise; +}; + +function control(run: Run): HarnessV1PromptControl { + return { + async submitToolResult({ toolCallId, output, isError }) { + run.pending.get(toolCallId)?.({ output, isError }); + }, + async submitToolApproval() { + // The adapter emits no approval requests; there is nothing to answer. + }, + done: run.done, + }; +} + +function listen(run: Run, signal: AbortSignal | undefined): void { + if (!signal) return; + if (signal.aborted) run.stop(); + else signal.addEventListener("abort", run.stop, { once: true }); + void run.done.catch(() => {}).finally(() => signal.removeEventListener("abort", run.stop)); +} + +/** Host-process `HarnessV1` implementation over `@bastani/atomic`. */ +export function createAtomicAdapter( + settings: AtomicSettings, +): HarnessV1 & { shutdown(): Promise } { + if (!ATOMIC_AUTH_MODES.includes(settings.auth)) { + throw new Error(`HARNESS_AUTH ${settings.auth} is not a supported atomic authentication mode`); + } + let runtime: Promise | undefined; + let open = new Set(); + + return { + specificationVersion: "harness-v1", + harnessId: "atomic", + builtinTools: {}, + supportsBuiltinToolFiltering: true, + + async doStart(startOptions: HarnessV1StartOptions): Promise { + if (startOptions.resumeFrom || startOptions.continueFrom) { + throw unsupported("resuming an in-memory session"); + } + let directory: string | undefined; + let sessionManager: SessionManager | undefined; + let settingsManager = SettingsManager.inMemory(SETTINGS); + let policy: TurnPolicy = { + hostNames: [], + structured: false, + systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, + }; + let live: { session: AgentSession; signature: string; release: () => void } | undefined; + let current: Run | undefined; + let closed: Promise | undefined; + + function hostTool(spec: HarnessV1ToolSpec): ToolDefinition { + return { + name: spec.name, + label: spec.name, + description: spec.description ?? "", + parameters: (spec.inputSchema ?? { type: "object" }) as ToolDefinition["parameters"], + maxResultSizeChars: Infinity, + async execute(toolCallId, params, signal) { + let run = current; + if (!run || run.stopped || signal?.aborted) throw new Error("The turn was aborted."); + let settled = Promise.withResolvers(); + let abort = () => settled.resolve({ output: "The turn was aborted.", isError: true }); + run.pending.set(toolCallId, settled.resolve); + signal?.addEventListener("abort", abort, { once: true }); + run.emit({ + type: "tool-call", + toolCallId, + toolName: spec.name, + input: JSON.stringify(params ?? {}), + }); + let outcome = await settled.promise; + signal?.removeEventListener("abort", abort); + run.pending.delete(toolCallId); + if (run.stopped || signal?.aborted) throw new Error("The turn was aborted."); + run.emit({ + type: "tool-result", + toolCallId, + toolName: spec.name, + result: (outcome.output ?? null) as NonNullable, + isError: outcome.isError, + }); + if (outcome.isError) throw new Error(serialize(outcome.output)); + return { content: [{ type: "text", text: serialize(outcome.output) }], details: {} }; + }, + }; + } + + function observe(session: AgentSession, event: AgentSessionEvent): void { + let run = current; + if (!run) return; + if (event.type === "turn_start") { + let offered = session.agent.state.tools.map(tool => tool.name); + if (!sameNames(offered, activeNames(policy))) { + run.failure ??= new ToolBoundaryError( + `Atomic offered unexpected tools: ${offered.join(", ") || "none"}.`, + ); + run.stop(); + } + if (run.stopped) session.agent.abort(); + return; + } + if (!("message" in event) || event.message.role !== "assistant") return; + if (event.type === "message_start") { + run.blocks.clear(); + } else if (event.type === "message_end") { + for (let block of run.blocks.values()) { + run.emit({ type: `${block.kind}-end`, id: block.id }); + } + run.blocks.clear(); + run.last = event.message; + } else if (event.type === "message_update" && !policy.structured) { + let update = event.assistantMessageEvent; + let kind = update.type.startsWith("text_") + ? "text" as const + : update.type.startsWith("thinking_") + ? "reasoning" as const + : undefined; + if (!kind || !("contentIndex" in update)) return; + let block = run.blocks.get(update.contentIndex); + if (!block) { + block = { id: crypto.randomUUID(), kind }; + run.blocks.set(update.contentIndex, block); + run.emit({ type: `${kind}-start`, id: block.id }); + } + if (update.type === "text_delta" || update.type === "thinking_delta") { + run.emit({ type: `${kind}-delta`, id: block.id, delta: update.delta }); + } else if (update.type === "text_end" || update.type === "thinking_end") { + run.emit({ type: `${kind}-end`, id: block.id }); + run.blocks.delete(update.contentIndex); + } + } + } + + async function prepare(turn: HarnessV1PromptTurnOptions): Promise { + if (turn.skills.length > 0) throw unsupported("skills"); + let hostNames = turn.tools.map(spec => spec.name); + if (hostNames.includes(ATOMIC_RESULT_TOOL_NAME)) { + throw new Error( + `Host tool name ${ATOMIC_RESULT_TOOL_NAME} is reserved for structured output.`, + ); + } + let models = await (runtime ??= createRuntime(settings)); + let model = resolveModel(models, settings, turn.model ?? settings.model); + let signature = JSON.stringify(turn.tools); + if (live && live.signature !== signature) { + live.release(); + await live.session.dispose(); + live = undefined; + } + policy.hostNames = hostNames; + policy.structured = false; + if (!live) { + directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); + sessionManager ??= SessionManager.inMemory(directory); + let loader = new DefaultResourceLoader({ + cwd: directory, + agentDir: directory, + settingsManager, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, + appendSystemPrompt: [], + extensionFactories: [atomicTurnExtension(policy)], + }); + await loader.reload(); + let created = await createAgentSession({ + cwd: directory, + agentDir: directory, + modelRuntime: models, + model, + fallbackModels: [], + sessionManager, + settingsManager, + resourceLoader: loader, + builtins: BUILTINS_OFF, + tools: [...hostNames, ATOMIC_RESULT_TOOL_NAME], + customTools: turn.tools.map(hostTool), + }); + let session = created.session; + let leak = hostLeak(session, created.extensionsResult.extensions.length, hostNames); + if (leak || closed) { + await session.dispose(); + throw leak + ? new ToolBoundaryError(`Atomic session is not isolated: ${leak}.`) + : new Error("Atomic session is closed."); + } + let registered = session.getAllTools().map(tool => tool.name); + console.log(`[agent] ${registered.length} tools: ${registered.sort().join(", ")}`); + live = { + session, + signature, + release: session.subscribe(event => observe(session, event)), + }; + } else if ( + live.session.model?.provider !== model.provider || live.session.model.id !== model.id + ) { + await live.session.setModel(model); + } + return live.session; + } + + async function close(): Promise { + open.delete(handle); + let run = current; + if (run) { + run.stop(); + await bounded(run.done, "Atomic turn did not stop.").catch(() => {}); + } + try { + if (live) { + live.release(); + await bounded(live.session.dispose(), "Atomic session disposal timed out."); + } + } finally { + live = undefined; + if (directory) await rm(directory, { recursive: true, force: true }); + } + } + + async function stopped(): Promise { + await (closed ??= close()); + return { + type: "resume-session", + harnessId: "atomic", + specificationVersion: "harness-v1", + data: {}, + }; + } + + let handle: HarnessV1Session = { + sessionId: startOptions.sessionId, + isResume: false, + + async doPromptTurn(turn: HarnessV1PromptTurnOptions): Promise { + if (closed) throw new Error("Atomic session is closed."); + if (current) throw new Error("Atomic session already has an active turn."); + let text = promptText(turn.prompt); + let agent = await prepare(turn); + let structured = turn.responseFormat?.type === "json"; + let expected = structured + ? [...policy.hostNames, ATOMIC_RESULT_TOOL_NAME] + : policy.hostNames; + agent.setActiveToolsByName(expected); + if (!sameNames(agent.getActiveToolNames(), expected)) { + throw new ToolBoundaryError( + `Atomic active tools do not match the turn: ${ + agent.getActiveToolNames().join(", ") + }.`, + ); + } + policy.structured = structured; + policy.systemPrompt = turnSystemPrompt(turn.instructions, turn.responseFormat); + policy.result = undefined; + + let run: Run = { + emit: turn.emit, + pending: new Map(), + blocks: new Map(), + stopped: false, + stop: () => { + if (run.stopped) return; + run.stopped = true; + for (let resolve of run.pending.values()) { + resolve({ output: "The turn was aborted.", isError: true }); + } + run.pending.clear(); + void bounded(agent.abort(), "Atomic abort timed out.").catch(() => {}); + }, + done: Promise.resolve(), + }; + current = run; + run.done = (async () => { + try { + if (!run.stopped) { + run.emit({ type: "stream-start", modelId: agent.model?.id }); + await agent.prompt(text, { expandPromptTemplates: false }); + } + if (run.failure) throw run.failure; + if (run.stopped) return; + if (run.last?.stopReason === "error" || run.last?.stopReason === "aborted") { + throw new Error(run.last.errorMessage ?? "Atomic turn failed."); + } + if (structured) { + if (!policy.result) { + throw new Error("Atomic turn ended without a structured result."); + } + let id = crypto.randomUUID(); + run.emit({ type: "text-start", id }); + run.emit({ type: "text-delta", id, delta: JSON.stringify(policy.result.value) }); + run.emit({ type: "text-end", id }); + } + run.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: ZERO_USAGE, + }); + run.emit({ + type: "finish", + finishReason: { unified: "stop", raw: undefined }, + totalUsage: ZERO_USAGE, + }); + } finally { + if (current === run) current = undefined; + } + })(); + listen(run, turn.abortSignal); + return control(run); + }, + + async doContinueTurn(turn): Promise { + let run = current; + if (!run) throw unsupported("continuing a turn that is no longer running"); + run.emit = turn.emit; + listen(run, turn.abortSignal); + return control(run); + }, + + async doCompact() { + throw unsupported("compaction"); + }, + async doSuspendTurn() { + throw unsupported("suspending a turn"); + }, + doDetach: stopped, + doStop: stopped, + async doDestroy() { + await (closed ??= close()); + }, + }; + open.add(handle); + return handle; + }, + + async shutdown() { + await Promise.allSettled([...open].map(session => session.doDestroy())); + }, + }; +} diff --git a/apps/server/src/harness/harnesses.test.ts b/apps/server/src/harness/harnesses.test.ts index fc7a62b5..95360422 100644 --- a/apps/server/src/harness/harnesses.test.ts +++ b/apps/server/src/harness/harnesses.test.ts @@ -4,8 +4,8 @@ import { harnesses, harnessFor, registerCredential, shutdownHarnesses } from "./ afterEach(shutdownHarnesses); -it("selects the tested Copilot SDK and Pi harnesses", () => { - expect(Object.keys(harnesses)).toEqual(["copilot-sdk", "pi"]); +it("selects the tested Copilot SDK, Pi, and Atomic harnesses", () => { + expect(Object.keys(harnesses)).toEqual(["copilot-sdk", "pi", "atomic"]); expect(harnessFor({ harness: "copilot-sdk" }).harnessId).toBe("copilot-sdk"); expect(() => harnessFor({ harness: "not-installed" })).toThrow("Unknown harness: not-installed"); }); @@ -46,6 +46,26 @@ it("refuses every Pi auth mode but ai-gateway on a non-loopback bind", () => { .toBe("pi"); }); +it("requires an explicit, Atomic-supported auth mode and refuses direct or unknown modes", () => { + expect(() => harnessFor({ harness: "atomic", host: "127.0.0.1" })) + .toThrow("HARNESS_AUTH is required for harness atomic"); + for (let harnessAuth of ["direct", "openai", "custom", "bogus"]) { + expect(() => harnessFor({ harness: "atomic", harnessAuth, host: "127.0.0.1" })) + .toThrow(`HARNESS_AUTH ${harnessAuth} is not a supported atomic authentication mode`); + } + expect(harnessFor({ harness: "atomic", harnessAuth: "auto", host: "::1" }).harnessId) + .toBe("atomic"); +}); + +it("refuses Atomic's host-login auto mode on a non-loopback bind but accepts ai-gateway", () => { + for (let host of ["0.0.0.0", "::", "192.168.1.2", "localhost.example"]) { + expect(() => harnessFor({ harness: "atomic", harnessAuth: "auto", host })) + .toThrow("HARNESS_AUTH for atomic on a non-loopback SERVER_HOST must be ai-gateway"); + } + expect(harnessFor({ harness: "atomic", harnessAuth: "ai-gateway", host: "0.0.0.0" }).harnessId) + .toBe("atomic"); +}); + it("registers credentials per session without reuse or rewriting", () => { let token = "session-token"; let release = registerCredential("session-id", () => token); diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index bf47951b..408d6f64 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -1,7 +1,9 @@ +import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; import { createCopilotSdk } from "./copilot-sdk/adapter"; import { createPiAdapter } from "./pi/adapter"; import type { HarnessV1 } from "@ai-sdk/harness"; +import type { AtomicAuthMode } from "./atomic/adapter"; const credentials = new Map string | undefined; @@ -14,9 +16,17 @@ function createPiHarness(settings: { auth?: string }): HarnessV1 & { shutdown(): return createPiAdapter({ auth: settings.auth as never }); } +function createAtomicHarness( + settings: { auth?: string }, +): HarnessV1 & { shutdown(): Promise } { + // Like Pi, Atomic takes neither a GitHub credential nor a credit limit. + return createAtomicAdapter({ auth: settings.auth as AtomicAuthMode }); +} + export const harnesses = { "copilot-sdk": createCopilotSdk, pi: createPiHarness, + atomic: createAtomicHarness, } satisfies Record< string, (settings: { @@ -41,6 +51,13 @@ function loopback(host: string): boolean { * Pi was never told to isolate. */ const PI_AUTH_MODES = new Set(["auto", "openai", "anthropic", "custom", "ai-gateway"]); +/** Harnesses whose auth modes are validated explicitly; only `ai-gateway` + * avoids a host login and may serve a non-loopback bind. */ +const AUTH_MODES = new Map>([ + ["pi", PI_AUTH_MODES], + ["atomic", new Set(ATOMIC_AUTH_MODES)], +]); + export function harnessFor(config: { harness: string; harnessAuth?: string; @@ -51,15 +68,20 @@ export function harnessFor(config: { } let auth = config.harnessAuth; let isLoopback = loopback(config.host ?? "127.0.0.1"); - if (config.harness === "pi") { + let modes = AUTH_MODES.get(config.harness); + if (modes) { if (!auth) { - throw new Error("HARNESS_AUTH is required for harness pi"); + throw new Error(`HARNESS_AUTH is required for harness ${config.harness}`); } - if (!PI_AUTH_MODES.has(auth)) { - throw new Error(`HARNESS_AUTH ${auth} is not a supported pi authentication mode`); + if (!modes.has(auth)) { + throw new Error( + `HARNESS_AUTH ${auth} is not a supported ${config.harness} authentication mode`, + ); } if (!isLoopback && auth !== "ai-gateway") { - throw new Error("HARNESS_AUTH for pi on a non-loopback SERVER_HOST must be ai-gateway"); + throw new Error( + `HARNESS_AUTH for ${config.harness} on a non-loopback SERVER_HOST must be ai-gateway`, + ); } } else if (auth && auth !== "direct" && auth !== "ai-gateway" && !isLoopback) { throw new Error("HARNESS_AUTH requires a loopback SERVER_HOST"); diff --git a/bun.lock b/bun.lock index 669bf6c7..ccdb8191 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "@ai-sdk/harness-pi": "catalog:harness", "@ai-sdk/mcp": "catalog:harness", "@ai-sdk/sandbox-just-bash": "catalog:harness", + "@bastani/atomic": "catalog:harness", "@chopin/dialect": "workspace:*", "@chopin/protocol": "workspace:*", "@chopin/question": "workspace:*", @@ -229,6 +230,7 @@ "@ai-sdk/harness-pi": "1.0.128", "@ai-sdk/mcp": "2.0.60", "@ai-sdk/sandbox-just-bash": "1.0.126", + "@bastani/atomic": "0.9.23", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", @@ -380,6 +382,28 @@ "@base-ui/utils": ["@base-ui/utils@0.3.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ=="], + "@bastani/atomic": ["@bastani/atomic@0.9.23", "", { "dependencies": { "@bastani/atomic-natives": "0.9.23", "@bastani/pi-ai": "0.9.23", "@dbos-inc/dbos-sdk": "4.25.14", "@earendil-works/pi-agent-core": "0.87.1", "@earendil-works/pi-client": "0.87.1", "@earendil-works/pi-protocol": "0.87.1", "@earendil-works/pi-tui": "0.87.1", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "@typesafe-ai/sdk": "0.6.0", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "embedded-postgres": "18.4.0-beta.17", "glob": "13.0.6", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "linkedom": "^0.18.13", "lru-cache": "11.5.3", "markit-ai": "0.5.3", "minimatch": "10.2.6", "open": "^11.0.1", "p-limit": "^7.3.2", "pg": "8.22.0", "proper-lockfile": "4.1.2", "semver": "7.8.5", "turndown": "^7.2.0", "typebox": "1.3.27", "undici": "8.10.2", "unpdf": "1.8.1", "yaml": "2.9.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "atomic": "dist/cli.js" } }, "sha512-h859zFfHSrxDZMalN0+6mkIcR0dXm99koTTkX7zYjj0Sdkl0uHyQZKsh7Eqqn7BIIJRxVqqRaejOhKjxqlaSlQ=="], + + "@bastani/atomic-natives": ["@bastani/atomic-natives@0.9.23", "", { "optionalDependencies": { "@bastani/atomic-natives-darwin-arm64": "0.9.23", "@bastani/atomic-natives-darwin-x64": "0.9.23", "@bastani/atomic-natives-linux-arm64-gnu": "0.9.23", "@bastani/atomic-natives-linux-arm64-musl": "0.9.23", "@bastani/atomic-natives-linux-x64-gnu": "0.9.23", "@bastani/atomic-natives-linux-x64-musl": "0.9.23", "@bastani/atomic-natives-win32-arm64-msvc": "0.9.23", "@bastani/atomic-natives-win32-x64-msvc": "0.9.23" } }, "sha512-zd4w/nEIc0hDlZWkmCCncG7/befEHtXo4uxIXNVNlKzTzdAgdUdq6VcMutvVsXK8v2kMGvHNdLkQO6hF/l050Q=="], + + "@bastani/atomic-natives-darwin-arm64": ["@bastani/atomic-natives-darwin-arm64@0.9.23", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v0PjvsXlWEn4nTaVjJ/TvMHg0hRJlNPkQ6ET+kcVP/m8IHLfzqa0w9pFKVV6Ez0lymQwQbFr2OUbe4zEBf7jvw=="], + + "@bastani/atomic-natives-darwin-x64": ["@bastani/atomic-natives-darwin-x64@0.9.23", "", { "os": "darwin", "cpu": "x64" }, "sha512-lOK1Eq0WQSj/PKesTayqdr1e/aOfE/vth81yvUfdt2AyI4QyrTJuVV4q72jBWEVhkLhhy4ql/6rDwEYGby5QVA=="], + + "@bastani/atomic-natives-linux-arm64-gnu": ["@bastani/atomic-natives-linux-arm64-gnu@0.9.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-fZ6qz1FvALylPrQCtuMEn0jH1PRLywpvdSPXEqMqFRFx7ST1DFjatSUBHNb3pv0nj52jDDTVOLZUCmjECSv2MQ=="], + + "@bastani/atomic-natives-linux-arm64-musl": ["@bastani/atomic-natives-linux-arm64-musl@0.9.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-cxAraZvtxuzi+qB8stflP/v5F0zOo7kFcbrYbzPRHS+uL6Uc+gAVeLJYsrTaWuTH+aN6HVpwXn+Q+4F8KU6l1Q=="], + + "@bastani/atomic-natives-linux-x64-gnu": ["@bastani/atomic-natives-linux-x64-gnu@0.9.23", "", { "os": "linux", "cpu": "x64" }, "sha512-7gwkDf4PCVEi2pRPIouxcFSdNnwACd5U1lvNle8/V2utcGRz/NlKnqxG+Ic8jYUafyzPgpS9xshUQoDIVo1KSw=="], + + "@bastani/atomic-natives-linux-x64-musl": ["@bastani/atomic-natives-linux-x64-musl@0.9.23", "", { "os": "linux", "cpu": "x64" }, "sha512-/KUQPXbVKJ1/jWQ9BjN0LqZE8j4rsIIZKHwy6LEpJ83eoHOwjJsHf9o2LPTRl3cEHHPECxME21ySJ/TTl1PIuw=="], + + "@bastani/atomic-natives-win32-arm64-msvc": ["@bastani/atomic-natives-win32-arm64-msvc@0.9.23", "", { "os": "win32", "cpu": "arm64" }, "sha512-ha0u9yelInx7F/5HKXArjLRWbKCqJDvx+Zl30JSTZoikL4GNKmZbVzrD20ZKT9N7XMZezbAb3SzElmeFElU82Q=="], + + "@bastani/atomic-natives-win32-x64-msvc": ["@bastani/atomic-natives-win32-x64-msvc@0.9.23", "", { "os": "win32", "cpu": "x64" }, "sha512-YMsQXBj5KHTK+dDwuTTzoQyi1kmKGEBlP8HMZeQ746CJZuoC8ekw2yzM5xogwudPEJw6KBtbqmO9lw0B7y0VXA=="], + + "@bastani/pi-ai": ["@bastani/pi-ai@0.9.23", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Z7s6dEeuQRj08avCqltxTcf+LRTYek1TZ3/tbSJxH8WELH/yNOzkIzcuSpcYzOZ7hrWFzwAB1AaKDITSgUSwpA=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], @@ -474,6 +498,8 @@ "@codemirror/view": ["@codemirror/view@6.43.6", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA=="], + "@dbos-inc/dbos-sdk": ["@dbos-inc/dbos-sdk@4.25.14", "", { "dependencies": { "commander": "^12.0.0", "pg": "^8.11.3", "serialize-error": "^8.1.0", "superjson": "^1.13.3", "ws": "^8.18.1", "yaml": "^2.8.3" }, "bin": { "dbos": "dist/src/cli/cli.js", "dbos-sdk": "dist/src/cli/cli.js" } }, "sha512-vBGuqlZQExzCiBpr6mW04BckqWi3n3+VLAv5p/odVfY3DGqDxxEscDXGDiUiFXYlaORkZa70GTnmzx8RVWxpzQ=="], + "@dprint/darwin-arm64": ["@dprint/darwin-arm64@0.50.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4d08INZlTxbPW9LK9W8+93viN543/qA2Kxn4azVnPW/xCb2Im03UqJBz8mMm3nJZdtNnK3uTVG3ib1VW+XJisw=="], "@dprint/darwin-x64": ["@dprint/darwin-x64@0.50.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZXWPBwdLojhdBATq+bKwJvB7D8bIzrD6eR/Xuq9UYE7evQazUiR069d9NPF0iVuzTo6wNf9ub9SXI7qDl11EGA=="], @@ -494,15 +520,35 @@ "@earendil-works/chord": ["@earendil-works/chord@0.85.1", "", { "dependencies": { "esbuild": "0.28.1" } }, "sha512-VDlkEC3dhCzQ5fcyH1OhG19dq+6jCn+rqc/iXFivwDYGR5anwo2RCiXij9PpHhqNR5GuhhE+Er69Zi1Sn4eY6w=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.85.1", "", { "dependencies": { "@earendil-works/chord": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", "@earendil-works/pi-telemetry": "^0.85.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-telemetry": "^0.87.1", "diff": "8.0.4", "ignore": "7.0.8", "typebox": "1.3.27", "yaml": "2.9.0" } }, "sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q=="], "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.74.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.91.1", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "^2.2.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "openai": "6.26.0", "partial-json": "^0.1.7", "typebox": "^1.1.24" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-ukQBHGDm20k9ZUS2cGjNN9vDJp/48r35xmvgSx3paCaC06r2N/PLuRZoJmwQ1ZM7f8T3072odv9YPWn+77w0LA=="], + "@earendil-works/pi-client": ["@earendil-works/pi-client@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-protocol": "^0.87.1" } }, "sha512-x8dfZ7ppAao9SDnZvD5/DErVdq2fI7hg4lFj7qEhtgo6wimrRafGSLsqLklFVjb6kxU2W3j9F4RZz0rRcb0cwA=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.85.1", "", { "dependencies": { "@earendil-works/chord": "^0.85.1", "@earendil-works/pi-agent-core": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", "@earendil-works/pi-tui": "^0.85.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.9.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ=="], - "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.85.1", "", {}, "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw=="], + "@earendil-works/pi-protocol": ["@earendil-works/pi-protocol@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "typebox": "1.3.27" } }, "sha512-48WVjryAIiKOJXnKgO15h2MT/oeH5uf0S4DykcVj3ZrxMTDSGxcUaS5VtvHtFQ1rZSSIjkVcblE13k0GksBuoQ=="], + + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.87.1", "", {}, "sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.87.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-YEH2vRyOeiO7hhN6j6AE6YwKSq2Kz2f3XR8bj1TbR+aGE/JsnY1hLPMI2pvaZfRM1n9Y00tejxFQ4zbzvF7nkQ=="], + + "@embedded-postgres/darwin-arm64": ["@embedded-postgres/darwin-arm64@18.4.0-beta.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Kg1ZMNFzkVIJs3g4V2UYEc5X005km9rGinxFBH8R1sOU2Rblvz4pt2Uf93Pu8Rk273Ue9HIdrbMPpgUqwjUi0Q=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.85.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig=="], + "@embedded-postgres/darwin-x64": ["@embedded-postgres/darwin-x64@18.4.0-beta.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-4tShSYWMxUQTSWoQAdLnHISvRj6L9gTch9/31ASJPg6wgyN64LDRwMcOINEWybM7N0ropJ3nTYhFT3UX1bKY5Q=="], + + "@embedded-postgres/linux-arm": ["@embedded-postgres/linux-arm@18.4.0-beta.17", "", { "os": "linux", "cpu": "arm" }, "sha512-VuD1nFasN7yG57zpJcp6MBXM0nXqGfb8GBQV+f1FGJ17+VMNYLIFLhFp99UlY4xnWG74FQC4NPYw8eCwSTJ6qg=="], + + "@embedded-postgres/linux-arm64": ["@embedded-postgres/linux-arm64@18.4.0-beta.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-TIzjtGnDGSD/LbdW0OWCEcbI+YVT0WKSfSr20TCkkP4UR8zeKe+QCZbO0/CM4hS9EWyZ4cDebjRvpRc3iMi6wg=="], + + "@embedded-postgres/linux-ia32": ["@embedded-postgres/linux-ia32@18.4.0-beta.17", "", { "os": "linux", "cpu": "ia32" }, "sha512-aSRe4kknqRHuedtpo/rDIaqa8r9VrKIgW9p5FkJyIFqUUkIkxRqND1dg8xrDRREcSUXRJKS0x+j/AuxflgYIfg=="], + + "@embedded-postgres/linux-ppc64": ["@embedded-postgres/linux-ppc64@18.4.0-beta.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zO2RRUFdKRPplrdhmglG39VXJZzDXu3P3ONAG1sFPhGh89vbk24Btd5P7uOmQLIpWehzL0s+UqUWoeE/ZUKvgw=="], + + "@embedded-postgres/linux-x64": ["@embedded-postgres/linux-x64@18.4.0-beta.17", "", { "os": "linux", "cpu": "x64" }, "sha512-jVw/MdDtIX/vICH/DKIe6/mHpiCggdx6QVyza4vt/NbcZFsL0KhwglF6F1Koqx3gRBZ9XtN+vi63EsqSyqOSxA=="], + + "@embedded-postgres/windows-x64": ["@embedded-postgres/windows-x64@18.4.0-beta.17", "", { "os": "win32", "cpu": "x64" }, "sha512-AwRerliA4IGWyW5jWBvHt5vidVUSB0QQW9Tt2y7ScnmifnCb/awfxZr4BkBSTv+gNt8Djcddqs+xSF5Z6/CkTg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -794,10 +840,12 @@ "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@mongodb-js/zstd": ["@mongodb-js/zstd@7.0.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "prebuild-install": "^7.1.3" } }, "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA=="], + "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], @@ -1024,7 +1072,7 @@ "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], "@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], @@ -1176,6 +1224,8 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typesafe-ai/sdk": ["@typesafe-ai/sdk@0.6.0", "", {}, "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260304.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260304.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260304.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260304.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260304.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260304.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260304.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260304.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-Xj0ZeHEy+yJ/bIg6psPwl0POvBf1j5u7IZAXsUqgvgWbMIvdM9JOGmhpifcj6j28LcXM6GTvXUoXwlatxJ73Qg=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260304.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TnTUxYt+dShRSoeOldx7VlKoEG+bvPHnyPEBImlNc7c3WP0AHYyNHrNg6EbLbzkOorARtd06J3Vk+XYzkrRzZg=="], @@ -1200,6 +1250,8 @@ "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -1222,6 +1274,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + "axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -1238,6 +1292,8 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], @@ -1264,7 +1320,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@6.0.0", "", {}, "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1302,6 +1358,10 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], @@ -1312,8 +1372,14 @@ "css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="], + "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="], + "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], + "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="], + + "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], @@ -1420,12 +1486,24 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dingbat-to-unicode": ["dingbat-to-unicode@1.0.2", "", {}, "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg=="], + + "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], + "dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="], + "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + "downshift": ["downshift@7.6.2", "", { "dependencies": { "@babel/runtime": "^7.14.8", "compute-scroll-into-view": "^2.0.4", "prop-types": "^15.7.2", "react-is": "^17.0.2", "tslib": "^2.3.0" }, "peerDependencies": { "react": ">=16.12.0" } }, "sha512-iOv+E1Hyt3JDdL9yYcOgW7nZ7GQ2Uz6YbggwXvKUSleetYhU2nXD482Rz6CzvM4lvI1At34BYruKAL4swRGxaA=="], "dprint": ["dprint@0.50.2", "", { "optionalDependencies": { "@dprint/darwin-arm64": "0.50.2", "@dprint/darwin-x64": "0.50.2", "@dprint/linux-arm64-glibc": "0.50.2", "@dprint/linux-arm64-musl": "0.50.2", "@dprint/linux-riscv64-glibc": "0.50.2", "@dprint/linux-x64-glibc": "0.50.2", "@dprint/linux-x64-musl": "0.50.2", "@dprint/win32-arm64": "0.50.2", "@dprint/win32-x64": "0.50.2" }, "bin": { "dprint": "bin.js" } }, "sha512-+0Fzg+17jsMMUouK00/Fara5YtGOuE76EAJINHB8VpkXHd0n00rMXtw/03qorOgz23eo8Y0UpYvNZBJJo3aNtw=="], + "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -1434,6 +1512,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="], + "embedded-postgres": ["embedded-postgres@18.4.0-beta.17", "", { "dependencies": { "async-exit-hook": "^2.0.1", "pg": "^8.7.3" }, "optionalDependencies": { "@embedded-postgres/darwin-arm64": "^18.4.0-beta.17", "@embedded-postgres/darwin-x64": "^18.4.0-beta.17", "@embedded-postgres/linux-arm": "^18.4.0-beta.17", "@embedded-postgres/linux-arm64": "^18.4.0-beta.17", "@embedded-postgres/linux-ia32": "^18.4.0-beta.17", "@embedded-postgres/linux-ppc64": "^18.4.0-beta.17", "@embedded-postgres/linux-x64": "^18.4.0-beta.17", "@embedded-postgres/windows-x64": "^18.4.0-beta.17" } }, "sha512-ORT/A1YiO6ecpRHpyIIBgyCR/8ASqqYZuFw11aX9PkqTX3FUM5+9sNOXY1mGIixxsm2TWSIA5WghJEk7A5ZxVw=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -1468,6 +1548,8 @@ "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], "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=="], @@ -1528,6 +1610,8 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], @@ -1538,7 +1622,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "grok-mermaid": ["grok-mermaid@0.2.2", "", {}, "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA=="], + "grok-mermaid": ["grok-mermaid@0.2.3", "", {}, "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg=="], "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], @@ -1560,10 +1644,14 @@ "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -1578,7 +1666,9 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "impeccable": ["impeccable@4.1.0", "", { "optionalDependencies": { "@impeccable/cli-darwin-arm64": "0.1.5", "@impeccable/cli-darwin-x64": "0.1.5", "@impeccable/cli-linux-arm64": "0.1.5", "@impeccable/cli-linux-x64": "0.1.5", "@impeccable/cli-windows-x64": "0.1.5" }, "bin": { "impeccable": "cli/bin/cli.js" } }, "sha512-hnfdoUK/Xg3qPtL0/5xzh92qKOtmREOZloCmFgnC1nYh3M81ihwCQEL2QWKntYl8qg1OG+jQ7wubR634PgTDIw=="], @@ -1608,6 +1698,8 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], @@ -1616,8 +1708,12 @@ "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], @@ -1646,6 +1742,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jszip": ["jszip@3.10.2", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ=="], + "just-bash": ["just-bash@2.14.5", "", { "dependencies": { "diff": "^8.0.2", "fast-xml-parser": "^5.7.3", "file-type": "^21.2.0", "ini": "^6.0.0", "minimatch": "^10.1.1", "modern-tar": "^0.7.3", "papaparse": "^5.5.3", "quickjs-emscripten": "^0.32.0", "re2js": "^1.2.1", "seek-bzip": "^2.0.0", "smol-toml": "^1.6.0", "sprintf-js": "^1.1.3", "sql.js": "^1.13.0", "turndown": "^7.2.2", "yaml": "^2.8.2" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.0.3" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-MCBGnRlDeZ/MM7mcw+ZuSGFMBsggajrmKz6e/hrOAN7syvVZkjiY+Vh2wyCwN/CdcnAX5SxbiQB51n5nrQuX+g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -1668,6 +1766,8 @@ "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1692,6 +1792,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -1700,16 +1802,22 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "mammoth": ["mammoth@1.13.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "markit-ai": ["markit-ai@0.5.3", "", { "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.3", "exifr": "^7.1.3", "fast-xml-parser": "^5.5.9", "jszip": "^3.10.1", "mammoth": "^1.9.0", "mupdf": "^1.27.0", "music-metadata": "^11.12.3", "rss-parser": "^3.13.0", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2" }, "bin": { "markit": "dist/main.js" } }, "sha512-h4nhn6a/SNXEdc3kLVtL37TspxjUNCNL0OM7LRWxd389ZByI/B7bjNNgxFdVAT0O+H7ZekSwLdVe/lws1l2AZQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-directive": ["mdast-util-directive@3.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="], @@ -1748,7 +1856,7 @@ "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], - "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + "media-typer": ["media-typer@2.0.0", "", {}, "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -1832,10 +1940,12 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], @@ -1844,6 +1954,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mupdf": ["mupdf@1.28.1", "", {}, "sha512-Gi11Ow2G1SlrXKJNZBL1eAIGFVih5+4ZKqjptamTVaj/5hnlrcVrVbyb7lHE2lfFKdxZPyv9ZtfOOq7XgjzEig=="], + + "music-metadata": ["music-metadata@11.16.1", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^2.1.0", "debug": "^4.4.3", "file-type": "^21.3.4", "media-typer": "^2.0.0", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-uR/mHK6eyfl8h3jVCobF5b38Mfg1IDJMiBxIVbmm5F+G1OXEFcUpQHveO84tJBAqvY93GCRY2R4kDyx7s06Rug=="], + "nano-css": ["nano-css@5.6.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "css-tree": "^1.1.2", "csstype": "^3.1.2", "fastest-stable-stringify": "^2.0.2", "inline-style-prefixer": "^7.0.1", "rtl-css-js": "^1.16.1", "stacktrace-js": "^2.0.2", "stylis": "^4.3.0" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw=="], "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], @@ -1866,6 +1980,8 @@ "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1878,16 +1994,22 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], + "oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="], + "p-limit": ["p-limit@7.3.3", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "papaparse": ["papaparse@5.7.0", "", {}, "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -1904,8 +2026,26 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], + + "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "pi-mcp-adapter": ["pi-mcp-adapter@2.12.1", "", { "dependencies": { "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "cross-spawn": "^7.0.6", "open": "^10.2.0", "recheck": "^4.5.0", "smol-toml": "^1.6.1", "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-tui": "*", "typebox": "*" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-tui", "typebox"], "bin": { "pi-mcp-adapter": "cli.js" } }, "sha512-lB0ANetzF0ld+l8ibgF6KcT7bj1LKvhgKig9LHMvr1DEBark6xZ36Jeb+JLib49/M03sBaCH5s3Jcmcy/OO5wg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1926,8 +2066,20 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + "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=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], @@ -1938,6 +2090,8 @@ "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], @@ -1972,7 +2126,7 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "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=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "recheck": ["recheck@4.5.0", "", { "dependencies": { "synckit": "0.9.2" }, "optionalDependencies": { "recheck-jar": "4.5.0", "recheck-linux-x64": "4.5.0", "recheck-macos-arm64": "4.5.0", "recheck-macos-x64": "4.5.0", "recheck-windows-x64": "4.5.0" } }, "sha512-kPnbOV6Zfx9a25AZ++28fI1q78L/UVRQmmuazwVRPfiiqpMs+WbOU69Shx820XgfKWfak0JH75PUvZMFtRGSsw=="], @@ -2010,6 +2164,8 @@ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], + "rtl-css-js": ["rtl-css-js@1.16.1", "", { "dependencies": { "@babel/runtime": "^7.1.2" } }, "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -2020,20 +2176,26 @@ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], - "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "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=="], + "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], + "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=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -2066,6 +2228,8 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="], @@ -2082,7 +2246,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -2100,6 +2264,8 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "superjson": ["superjson@1.13.3", "", { "dependencies": { "copy-anything": "^3.0.2" } }, "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg=="], + "synckit": ["synckit@0.9.2", "", { "dependencies": { "@pkgr/core": "^0.1.0", "tslib": "^2.6.2" } }, "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw=="], "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], @@ -2140,13 +2306,21 @@ "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], + "turndown-plugin-gfm": ["turndown-plugin-gfm@1.0.2", "", {}, "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], + "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], - "undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + + "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -2168,6 +2342,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "unpdf": ["unpdf@1.8.1", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "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=="], @@ -2216,14 +2392,22 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -2232,6 +2416,8 @@ "yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -2242,37 +2428,79 @@ "@ai-sdk/provider-utils/undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], - "@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], - - "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], - "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1138.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw=="], - "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@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=="], + "@bastani/atomic/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "@bastani/atomic/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], + + "@bastani/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.124.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA=="], + + "@bastani/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/eventstream-handler-node": "^3.972.34", "@aws-sdk/middleware-eventstream": "^3.972.29", "@aws-sdk/middleware-websocket": "^3.972.52", "@aws-sdk/token-providers": "3.1127.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA=="], + + "@bastani/pi-ai/@google/genai": ["@google/genai@2.21.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q=="], + + "@bastani/pi-ai/http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], + + "@bastani/pi-ai/https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], + + "@bastani/pi-ai/openai": ["openai@7.19.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ=="], + + "@bastani/pi-ai/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], + "@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=="], + "@dbos-inc/dbos-sdk/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@dbos-inc/dbos-sdk/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + "@earendil-works/chord/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.85.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.123.0", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.85.1", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ=="], + "@earendil-works/pi-agent-core/@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.87.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA=="], "@earendil-works/pi-agent-core/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "@earendil-works/pi-agent-core/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], + "@earendil-works/pi-ai/typebox": ["typebox@1.3.34", "", {}, "sha512-wbnzrXXDW8xEFHDZZs2jo1MkhaYlKAY4FRhpBc1+2LF1fZVBGCXGdLEhA/Z/NBbgzJMFfeM8m7elKPa/+KxaUQ=="], + "@earendil-works/pi-client/@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + + "@earendil-works/pi-coding-agent/@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.85.1", "", { "dependencies": { "@earendil-works/chord": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", "@earendil-works/pi-telemetry": "^0.85.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA=="], + "@earendil-works/pi-coding-agent/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.85.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.123.0", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.85.1", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ=="], + "@earendil-works/pi-coding-agent/@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.85.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig=="], + + "@earendil-works/pi-coding-agent/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "@earendil-works/pi-coding-agent/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@earendil-works/pi-coding-agent/grok-mermaid": ["grok-mermaid@0.2.2", "", {}, "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA=="], + + "@earendil-works/pi-coding-agent/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@earendil-works/pi-coding-agent/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@earendil-works/pi-coding-agent/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "@earendil-works/pi-coding-agent/undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], + + "@earendil-works/pi-protocol/@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + + "@earendil-works/pi-protocol/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], + + "@earendil-works/pi-tui/marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -2286,6 +2514,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "bl/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=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "body-parser/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], @@ -2300,26 +2530,54 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "dom-serializer/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "domhandler/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "just-bash/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "just-bash/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "just-bash/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "just-bash/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + "jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "markit-ai/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "markit-ai/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "micromark-extension-math/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], - "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "music-metadata/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "pi-mcp-adapter/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA=="], + + "pi-mcp-adapter/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -2328,18 +2586,34 @@ "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "seek-bzip/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], "stacktrace-gps/source-map": ["source-map@0.5.6", "", {}, "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA=="], + "tar-stream/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=="], + + "tunnel-agent/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "type-is/media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + "unidiff/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "uvu/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "@bastani/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ=="], + + "@bastani/pi-ai/http-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + + "@bastani/pi-ai/https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + "@earendil-works/chord/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@earendil-works/chord/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -2392,28 +2666,222 @@ "@earendil-works/chord/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.123.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw=="], + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.124.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/eventstream-handler-node": "^3.972.34", "@aws-sdk/middleware-eventstream": "^3.972.29", "@aws-sdk/middleware-websocket": "^3.972.52", "@aws-sdk/token-providers": "3.1127.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@google/genai": ["@google/genai@2.21.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], "@earendil-works/pi-agent-core/@earendil-works/pi-ai/openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], + "@earendil-works/pi-client/@earendil-works/chord/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "@earendil-works/pi-coding-agent/@earendil-works/pi-agent-core/@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.85.1", "", {}, "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw=="], + "@earendil-works/pi-coding-agent/@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.123.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw=="], "@earendil-works/pi-coding-agent/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + "@earendil-works/pi-coding-agent/@earendil-works/pi-ai/@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.85.1", "", {}, "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw=="], + + "@earendil-works/pi-coding-agent/@earendil-works/pi-ai/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + "@earendil-works/pi-coding-agent/@earendil-works/pi-ai/openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], + "@earendil-works/pi-coding-agent/@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + "htmlparser2/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "mammoth/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "pi-mcp-adapter/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@earendil-works/pi-agent-core/@earendil-works/chord/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/http-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@earendil-works/pi-client/@earendil-works/chord/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], "@earendil-works/pi-coding-agent/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], "@earendil-works/pi-coding-agent/@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@earendil-works/pi-protocol/@earendil-works/chord/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + + "bl/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "htmlparser2/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "tar-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], } } diff --git a/docs/architecture.md b/docs/architecture.md index 3f6964f5..dd109b3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,8 @@ and [Self-hosting](self-hosting.md) for deployment. blocks starting research from a child; the API may accept the request, but publication validation rejects linking a grandchild. - The **Planner** is the current name of Chopin's hosted document agent, backed - by GitHub Copilot by default or by Pi under `HARNESS=pi`. + by GitHub Copilot by default, by Pi under `HARNESS=pi`, or by an in-process + Atomic session under `HARNESS=atomic`. - A **coding agent** is an external MCP client that creates, revises, or implements a document from its own local workspace. @@ -53,7 +54,7 @@ flowchart LR C[Local coding agent] -->|Bearer-authenticated MCP| S S --> P[(PostgreSQL)] S --> G[GitHub API] - S --> A[Harness model provider: Copilot or Pi] + S --> A[Harness model provider: Copilot, Pi, or Atomic] S --> W[Built web client] ``` @@ -473,6 +474,7 @@ Treat these as implementation work, not guarantees to build new behavior upon. `apps/server/src/harness/session.ts`, and `apps/server/src/harness/github-tools.ts` - Copilot SDK adapter: `apps/server/src/harness/copilot-sdk/adapter.ts` +- Atomic SDK adapter: `apps/server/src/harness/atomic/adapter.ts` - Channel routes and IDs: `apps/server/src/channels/` - Research requests and child publication: `apps/server/src/research/service.ts` - Inline request state and anchored children: `apps/web/src/research-requests.ts` diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 89f7e8b0..fae44c3c 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -12,21 +12,22 @@ that is an implementation limitation, not the document model's boundary. Chopin runs the Planner through `@ai-sdk/harness`: `HARNESS` selects one adapter from a code-owned map, defaulting to `copilot-sdk`, a host-process adapter over -`@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, is a second reviewed -adapter and requires an explicit `HARNESS_AUTH`. The harness owns the agent -loop and model; Chopin owns every tool the Planner can call. See -[Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` selection and -adapter trust. +`@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, and `atomic`, which +embeds Atomic's headless SDK (`@bastani/atomic`) in the server process, are +further reviewed adapters. Both require an explicit `HARNESS_AUTH`. The +harness owns the agent loop and model; Chopin owns every tool the Planner can +call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` +selection and adapter trust. ## Ownership The first eligible editor to invoke the Planner or start a model-backed research request supplies the GitHub App user access token for that channel and, under the default `copilot-sdk` harness, the Copilot entitlement. Under `HARNESS=pi` -the owner supplies only the GitHub token; model access comes from the -operator's `HARNESS_AUTH` mode. The user must pass instance admission and have -repository push or administration access. Ownership is assigned atomically in -storage and guarded by a generation token. +and `HARNESS=atomic` the owner supplies only the GitHub token; model access +comes from the operator's `HARNESS_AUTH` mode. The user must pass instance +admission and have repository push or administration access. Ownership is +assigned atomically in storage and guarded by a generation token. That process-local login owns the channel's model usage until it expires, logs out, the server restarts, or the authenticated reset API releases it. The @@ -64,6 +65,19 @@ Under `HARNESS=pi`, Chopin patches `@ai-sdk/harness-pi` 1.0.128 so Pi does not load `AGENTS.md` or `CLAUDE.md` context files from the host filesystem. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness). +Under `HARNESS=atomic`, each harness session owns one in-process Atomic +`AgentSession`. It is built with every shipped Atomic package disabled +(workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding +tools, and its resource loader discovers no extensions, skills, prompt +templates, themes, or context files. Its working and configuration directory is +an empty private temporary directory, and its session, settings, and +credentials stay in memory. A per-turn hook replaces the whole system prompt +with the turn's instructions, so the model never receives Atomic's +coding-agent preamble. The adapter fails the turn before any model request +when the live session reports an extension, tool, context file, skill, prompt +template, or system prompt beyond that set. It checks the tools offered to the +model again before every model request. + Available capabilities are: - Chopin document, question, relationship, and implementation-graph tools (with @@ -213,6 +227,7 @@ current production interface lets a person approve the draft. See - Planner session lifecycle: `apps/server/src/harness/session.ts` - Host-executed GitHub MCP tools: `apps/server/src/harness/github-tools.ts` - Copilot SDK adapter: `apps/server/src/harness/copilot-sdk/adapter.ts` +- Atomic SDK adapter: `apps/server/src/harness/atomic/adapter.ts` - Planner prompt and document tools: `apps/server/src/agent/planner.ts` and `apps/server/src/agent/tools.ts` - Repository-fixed tools: `apps/server/src/agent/repository.ts` diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 11ca1e07..e51eb5bd 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -57,14 +57,15 @@ bearer tokens and browser sessions traverse it. Chopin runs its hosted agent through `@ai-sdk/harness`. `HARNESS` selects one adapter from a code-owned map: the default is `copilot-sdk`, a host-process -wrapper over `@github/copilot-sdk`, and `pi` is a second reviewed adapter over -`@ai-sdk/harness-pi`. Adding an adapter to that map is a reviewed trust -decision, not a runtime plugin choice: an adapter's `builtinTools` and -`supportsBuiltinToolFiltering` are self-declarations, and Chopin's contract -suite checks those declarations and the tools a session actually receives, -but it cannot prove what the underlying runtime does internally. Only -adapters that ship in this repository and pass that suite belong in -`harnesses`. +wrapper over `@github/copilot-sdk`; `pi` is a second reviewed adapter over +`@ai-sdk/harness-pi`; and `atomic` embeds Atomic's headless SDK +(`@bastani/atomic`) in the server process. Adding an adapter to that map is a +reviewed trust decision, not a runtime plugin choice: an adapter's +`builtinTools` and `supportsBuiltinToolFiltering` are self-declarations, and +Chopin's contract suite checks those declarations and the tools a session +actually receives, but it cannot prove what the underlying runtime does +internally. Only adapters that ship in this repository and pass that suite +belong in `harnesses`. For `copilot-sdk`, `direct` and `ai-gateway` are explicit `HARNESS_AUTH` modes that may be used on any bind. A mode that could fall back to a subscription @@ -114,6 +115,74 @@ it. The Pi contract suite (`apps/server/src/harness/pi.contract.test.ts`) covers this against the real Pi agent loop; run it before bumping `@ai-sdk/harness-pi` or `@earendil-works/pi-coding-agent`. +`HARNESS=atomic` runs Atomic 0.9.23 in the Chopin server process through its +headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use +RPC mode, or substitute Atomic for Pi's runtime. Each harness session owns one +in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see +[Hosted agent](hosted-agent.md#runtime-isolation) for the isolation it +enforces. + +`HARNESS=atomic` requires an explicit `HARNESS_AUTH`, either `auto` or +`ai-gateway`. Unset, `direct`, and every other value are refused at startup. + +- `auto` copies the host operator's Atomic login into memory when the first + turn starts: `$ATOMIC_CODING_AGENT_DIR/auth.json` when that variable is set, + otherwise `~/.atomic/agent/auth.json` over the legacy `~/.pi/agent/auth.json`. + It also resolves provider keys from the process environment, such as + `ANTHROPIC_API_KEY`, and ambient cloud credentials, such as an AWS profile or + Google application default credentials. Because every admitted writer's turns + would use the operator's own subscription or keys, `auto` is refused unless + `SERVER_HOST` is loopback-only. +- `ai-gateway` starts without stored credentials, reads `AI_GATEWAY_API_KEY`, + and accepts only `vercel-ai-gateway` models. It is the only mode allowed on a + non-loopback bind. + +The adapter never writes credentials to disk. It reads the host login without a +lock file, keeps refreshed OAuth tokens in memory, and neither reads nor writes +Atomic's `models.json` or `models-store.json`. + +Under `atomic`, `MODEL` is required and must name a `provider/model` from +Atomic's catalog: for example `vercel-ai-gateway/anthropic/claude-sonnet-4.6` +under `ai-gateway`, or `github-copilot/gpt-6-luna` under `auto` with a GitHub +Copilot login. An integration that passes Atomic a failed model lookup gets +another model without warning. This adapter looks the model up itself and +fails the turn before any model request. + +Structured output uses the same approach as Pi: an inline Atomic extension +registers a terminating result tool, enabled only for a turn that requests +structured output and blocked on every other turn. The tool records the calling +model's own arguments. The adapter does not use Atomic's +`createStructuredOutputTool`, which infers the answer again with a second model +call. The Atomic contract suite +(`apps/server/src/harness/atomic.contract.test.ts`) covers isolation, model +resolution, host tools, structured output, and abort against Atomic's real +agent loop. Run it before bumping `@bastani/atomic`. + +Atomic caveats: + +- Atomic's SDK defaults suit a local coding agent, not Chopin. By default it + enables its shipped packages, including a mandatory Intercom, and its coding + tools. It discovers host resources, gives turns without instructions a + coding-agent system prompt, and saves tool results over 50,000 characters to a + temporary file. The adapter overrides each default and fails closed on the + ones it can observe, but a new Atomic release can add a default the suite does + not check. +- Sessions live only in memory. `doStop` returns a state the adapter refuses to + resume, so a session never survives a restart. Chopin does not resume + harness sessions. +- Compaction, suspending a turn, and skills are unsupported. Atomic's automatic + retries remain on. +- Under `auto`, refreshing an OAuth token in memory can rotate the refresh token + stored by the host CLI, which may then ask the operator to sign in again. + `auto` also runs `!command` API-key entries in `auth.json` to resolve them. +- There is no per-session credit limit like Copilot's, so a worker's + `maxAiCredits` does not apply. Use the provider's spend limits. +- `@bastani/atomic` adds more than 250 MB of installed dependencies on Linux + x64, including glibc and musl native modules and the embedded PostgreSQL + that only Atomic workflows use. Expect a larger image. +- Atomic depends on `typebox` 1.3.27 while Chopin pins 1.3.7. Host tool schemas + pass to Atomic as plain JSON Schema, so the two versions never meet. + For a reviewed adapter that consumes a shared operator key, every admitted writer's turns would bill that key. Chopin adds no billing quotas of its own in this revision, across jobs or users; use the model provider's spend limits and @@ -131,7 +200,7 @@ turns of its job stage, not as a platform-wide budget; see - A GitHub App owned by the deployment. - At least one user with repository push or administration access. - For `copilot-sdk`, an active Copilot entitlement for each user who may own a - hosted agent session. For `pi`, model credentials for the chosen + hosted agent session. For `pi` or `atomic`, model credentials for the chosen `HARNESS_AUTH` mode instead. ## Register the GitHub App @@ -169,28 +238,28 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| ------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi`. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk` or `pi`). An unknown name refuses at startup. | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| Variable | Default | Meaning | +| ------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. @@ -365,7 +434,8 @@ After the first deployment: 5. Create a channel with a user who has push or administration access. 6. Open the channel in a second browser and verify presence and live edits. 7. Send one `@chopin` request to verify model access (the owner's Copilot - entitlement for `copilot-sdk`, or the `HARNESS_AUTH` credentials for `pi`) + entitlement for `copilot-sdk`, or the `HARNESS_AUTH` credentials for `pi` or + `atomic`) and the hosted agent runtime. 8. Connect a local coding agent and call `list_documents` if MCP is part of the deployment's intended surface. diff --git a/package.json b/package.json index ca38be76..84ed5f7f 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@ai-sdk/harness-pi": "1.0.128", "@ai-sdk/mcp": "2.0.60", "@ai-sdk/sandbox-just-bash": "1.0.126", + "@bastani/atomic": "0.9.23", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", From 37fc4ffa463e31d2b6743c4f4a94157f10b810c8 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 18:49:46 +0000 Subject: [PATCH 02/17] Keep the Atomic harness research notes The ralph run's research stage mapped Atomic's SDK onto HarnessV1: event translation, host tool round trips, the tool boundary, auth modes, and caveats such as no cross-process resume and oversized tool result truncation. Keep it with the branch so review can continue on another machine. The run was stopped during its first review round. Both reviewers had no blocking findings; reviewer B noted one doc nit: self-hosting.md names only ATOMIC_CODING_AGENT_DIR, but Atomic also honors legacy PI_CODING_AGENT_DIR for the auto-mode auth.json path. The Docker build could not be verified on this machine because its Docker Desktop network fails TLS to registry.npmjs.org (npm.pkg.github.com and api.github.com still work), so bun install inside the image cannot fetch the new lockfile entries. Assistant-workflow: ralph (run 0cfa5220-f488-43ef-8bb5-2f54cf5141f2) Assistant-model: Claude Opus 5.5 Assistant-duration: 72m abandoned, estimated 90m Assistant-verification: docker build unavailable: container TLS to registry.npmjs.org fails with UNKNOWN_CERTIFICATE_VERIFICATION_ERROR on this Docker Desktop host User-preference: Stop the workflow and hand off when the local environment blocks verification; continue on another machine Co-authored-by: Alex Lavaee --- ...-to-chopin-s-hosted-planner-built-direc.md | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 research/2026-09-28-keepcontext-add-an-atomic-harness-adapter-to-chopin-s-hosted-planner-built-direc.md diff --git a/research/2026-09-28-keepcontext-add-an-atomic-harness-adapter-to-chopin-s-hosted-planner-built-direc.md b/research/2026-09-28-keepcontext-add-an-atomic-harness-adapter-to-chopin-s-hosted-planner-built-direc.md new file mode 100644 index 00000000..2af2d1a8 --- /dev/null +++ b/research/2026-09-28-keepcontext-add-an-atomic-harness-adapter-to-chopin-s-hosted-planner-built-direc.md @@ -0,0 +1,433 @@ +# Research report: adding an `atomic` Planner harness to Chopin on the Atomic SDK + +The research is done and the design is feasible. I ran a scratch spike against the installed Atomic 0.9.23 and the repo's own model stub, in `/tmp/atomic-spike/` outside the repository. It confirmed the in-process design: `createAgentSession()` with a custom `DefaultResourceLoader`, in-memory session and settings managers, and a `ModelRuntime` using `AuthStorage.inMemory()`. I changed no repository files. + +The SDK does **not** give most of the guarantees by default. The adapter has to set them explicitly and check them itself. Five problems need handling, and each has an SDK option or hook that solves it: + +1. **Unknown models fall back silently.** Passing `model: undefined` makes Atomic pick the first available model with no `modelFallbackMessage`. The adapter must look the model up itself and fail the turn. +2. **An empty system prompt brings in Atomic's coding-assistant prompt.** That text includes an Atomic docs section. The adapter must always force the exact per-turn system prompt. +3. **`prompt()` does not reject on abort or model error.** It resolves normally; the failure only shows on the last assistant message and `agent.state.errorMessage`. +4. **A host tool that ignores its abort signal makes `abort()` and `prompt()` hang.** The adapter must settle pending host-tool promises when the turn aborts. +5. **Large tool results are written to disk.** Results over 50,000 characters are saved to a temp file and the model gets only a preview and a path. Host tools should set `maxResultSizeChars: Infinity`. + +No `bun patch` looks necessary. Every safety need maps to a supported option or extension hook. + +## Contract amendments received + +None. No steering or follow-up messages arrived during this stage. The launch objective and acceptance criteria are the whole contract. + +## Compatibility posture + +`breaking_changes_allowed: false` for the existing `copilot-sdk` and `pi` harnesses. The acceptance criteria say not to regress them. This is an addition to the code-owned `harnesses` map. Existing behaviour that must stay the same: + +- `harnessFor` validation for `copilot-sdk` and `pi`. +- The `harnessContract` suite in `contract.ts`, which the other adapters also run. +- The `PLANNER_TOOL_NAMES` boundary in `chat/service.ts`. +- The Pi patch and Pi's result tool. + +--- + +## 1. Codebase facts (verified this session) + +### Harness selection — `apps/server/src/harness/harnesses.ts` + +- The map is `harnesses = { "copilot-sdk": createCopilotSdk, pi: createPiHarness }`. Its `satisfies` type is `(settings: {credentials, limits, auth?}) => HarnessV1` (lines 17-27). +- The cached singleton is `selected: HarnessV1 & { shutdown(): Promise }` (line 31). `shutdownHarnesses()` calls `selected?.shutdown()` (lines 89-93). +- `loopback()` accepts `localhost`, `::1`, `[::1]` and `127.x.x.x` (lines 33-37). +- Pi auth modes are `PI_AUTH_MODES = {auto, openai, anthropic, custom, ai-gateway}` (line 42). `harnessFor` for Pi (lines 54-63) does three things: + - requires `HARNESS_AUTH`; + - rejects unknown modes; + - allows only `ai-gateway` on a non-loopback host. +- Every other harness goes through the generic rule: an auth value other than `direct` or `ai-gateway` needs loopback (lines 64-66). An `atomic` branch is needed, or `atomic` will silently use the generic rule. +- `createPiHarness` forwards only `auth` (lines 11-15). The Atomic factory should follow the same pattern; credit `limits` are Copilot-specific. + +### Tests to update — `harnesses.test.ts` + +- Line 8 pins `Object.keys(harnesses)` to `["copilot-sdk", "pi"]`. It must become `["copilot-sdk", "pi", "atomic"]`, or whatever order registration uses. +- Add the Atomic auth-mode and loopback matrix in the same style as the Pi tests at lines 28-47. + +### Shared contract — `apps/server/src/harness/contract.ts` + +`harnessContract(name, createHarness, createSandboxSession?)` has three tests: + +1. **Tools boundary** (prompt `"tools"`, host tools `first_host` and `second_host`): + - the turn sees exactly the host tools; + - `builtinTools` is empty, or `supportsBuiltinToolFiltering` is true with `builtinToolFiltering: {mode:"allow", toolNames:[]}`; + - no non-host `tool-call`/`tool-result` events appear; + - destroy runs once. +2. **Structured output** (prompt `"output"`, `Output.object({answer: string})`): `result.output` parses and `responseFormat.type === "json"` is forwarded. +3. **Abort** (prompt `"abort"`): `abortSignal` is forwarded as the same object; after abort `hasUnfinishedTurn()` is false; destroy is idempotent. + +The suite does **not** test a plain text turn, and it does not force a host-tool round trip. Criterion 6 requires both, so `atomic.contract.test.ts` needs extra cases. Recommendation: script the stub so `"tools"` makes a call to `first_host` and then returns text, and add an explicit `"plain"` text test. `contract.ts` is shared with `copilot-sdk` and `pi`; changing it risks regressing them. + +### Pi reference — `apps/server/src/harness/pi.contract.test.ts` and `pi/model-stub.ts` + +- **The stub:** + - It is an OpenAI-completions SSE server. + - It is scripted by the last user message and `hasPriorToolResult`, which is true when any message has `role: "tool"`. + - It supports `text`, `tool` and `hold` turns; `hold` closes the response on request abort. + - It records `prompt`, `system`, `toolNames` and `hasPriorToolResult` for each request. +- **Pi's extra tests** can be copied almost directly: + - one model request on a structured turn; + - no result tool offered on a plain turn; + - a rogue result-tool call is blocked and kept out of the stream; + - quoted earlier chat does not enable the tool; + - host `AGENTS.md` isolation; + - an unknown model fails with no model request. +- **Stub caveat (seen in the spike):** `hasPriorToolResult` scans the whole history. An Atomic session keeps multi-turn history, so any later turn in a session that already made a tool call gets `"Done."`. Each contract case uses a fresh `HarnessAgent` session, so that is fine. Multi-turn unit tests need a fresh session per scenario or a stub keyed differently. + +### Pi's result-tool approach — `apps/server/src/harness/pi/adapter.ts` + +- `piResultToolExtension(state)` (lines 53-80): + - registers a `Type.Object({}, {additionalProperties:true})` tool that returns `terminate: true`; + - on `before_agent_start`, adds or removes the tool from the active set based on `state.structured`; + - on `tool_call`, blocks the tool when the turn is not structured. +- `runOutputTurn` (lines 106-191): + - reserves the result tool's name; + - hides the result tool's parts; + - in JSON mode drops prose, reasoning and inner `finish-step`s; + - on `finish`, emits `text-start`/`text-delta`/`text-end` with the JSON and one synthetic `finish-step`; + - rejects `done` when no result or an invalid result arrived. +- The extension is typed against Pi's `ExtensionAPI` from `@earendil-works/pi-coding-agent`. Atomic's `ExtensionAPI` has the same members (`registerTool`, `on("before_agent_start")`, `on("tool_call")`, `getActiveTools`, `setActiveTools`) but they are different TypeScript types (`dist/core/extensions/api-types.d.ts:54,71,77,146,150`). +- Recommendation: write an Atomic-typed twin of the extension, or share only the name, instruction text and stream-suppression helpers. Forcing one generic type over two different overload sets is likely to fight `tsgo`. + +### Copilot reference — `apps/server/src/harness/copilot-sdk/adapter.ts` + +- **Host tools** (lines 180-212): the handler emits `tool-call`, parks a resolver keyed by `toolCallId`, then on `submitToolResult` emits `tool-result` and throws when `isError` is set. +- **Terminal result tool** (lines 214-231): it emits the JSON as text. +- **Fail-closed check** (lines 107-138): `assertHostOnly` compares live tool metadata with exactly the host names and logs `[agent] N tools: …`. AGENTS.md "Diagnostics" expects this kind of log. +- **Abort** (lines 435-447): resolves every pending tool with an error, then settles. +- **Unsupported methods** (lines 472-476): continue, suspend, detach and stop throw `HarnessCapabilityUnsupportedError`. Criterion 1 requires the Atomic adapter to go further; see §3. +- **Defaults:** `builtinTools: {}` and `supportsBuiltinToolFiltering: true` (lines 279-280). The Atomic adapter should declare the same. + +### How Chopin uses harnesses (subagent report, spot-checked) + +- **Planner:** `service.ts:891-901` and `session.ts` open **one HarnessAgent session per chat turn** and destroy it in `finally` (I read `service.ts:1020-1060`). +- **Workers:** + - The summary worker runs **several `generate` turns on one worker session** with `output` (I read `document-summary.ts:225-260`). + - Research workers use `structuredAgent`, which has `output` plus an optional `web_search` host tool (`agents.ts:94-125`). + - So the Atomic adapter must support multi-turn sessions and a host-tool round trip inside a structured turn. +- **`translate()`** (`chat/service.ts` ~1156-1272): + - a `tool-call` outside `PLANNER_TOOL_NAMES` aborts the turn with a boundary failure; + - `tool-approval-request` aborts; + - `error` parts are posted to chat. +- Nothing in Chopin calls `continueStream`, `stop`, `detach` or `doCompact` in production (grep of `apps/server/src`). Continue and stop are required only by the contract. +- **`config.ts:49-58`:** `DEFAULT_MODEL = "gpt-6-luna"`, and `MODEL` is required only for `HARNESS=pi`. In the spike, Atomic's catalog contains `github-copilot/gpt-6-luna`, but a bare `gpt-6-luna` has no provider. Mirroring the Pi rule for `atomic` is my proposal; it is not a contract clause. + +### Packages + +- The root `package.json` `catalogs.harness` has no `@bastani/atomic`. Add `"@bastani/atomic": "0.9.23"` there and `"@bastani/atomic": "catalog:harness"` in `apps/server/package.json`. +- `bun.lock` has no `@bastani` entries. `node_modules/.bun/@bastani+atomic@0.9.20-alpha.5` is stale, left over from an older branch; the lockfile will be regenerated. +- `npm view` confirms `latest = 0.9.23`, published 2026-09-27. It depends on `typebox 1.3.27`, while Chopin's catalog pins `1.3.7`. TypeBox's `TSchema` is an empty interface (`typebox/build/type/types/schema.d.mts:1`), so the mismatch should be type-compatible, but that is unverified in `tsgo`. +- The package is 41 MB unpacked, with heavy dependencies: `embedded-postgres`, `@dbos-inc/dbos-sdk`, `pg`, platform natives for linux-x64/arm64 gnu/musl, darwin and win32. There is no `postinstall`. Expect a noticeably larger Docker image; the `oven/bun` Debian base should use the gnu natives. + +### Lint and format + +- `.oxlintrc.json` restricts `@github/copilot-sdk` imports to `harness/copilot-sdk/`. A matching restriction for `@bastani/atomic` to `harness/atomic/` would follow convention; that is my proposal, not a requirement. +- `dprint.json` sets tabs, width 100 and double quotes. +- `bun run ci` runs `dprint check && oxlint && bun scripts/check-tokens.ts`; the token check only inspects CSS. + +### History + +- Branch `backup/main-before-atomic-rewind` (commit `49419cf`) holds an earlier Atomic SDK backend, from before the move to the `HarnessAgent` architecture: + - `apps/server/src/agent/atomic.ts` and `events.ts`; + - an inert custom `ResourceLoader`, `builtins` all false, an `AMBIENT` tool denylist; + - `resolveAtomicModel` built on `runtime.getModel`; + - an `abortEpoch` guard so an abort is not reported as a provider error. + It is useful prior art, but it was written against 0.9.20-alpha.5 and a Copilot-shaped event bridge. +- Relevant `User-preference` trailers: + - "Prefer an upstream-supported extension point over working around third-party adapter races" (`a3ab603`); + - "Patch pinned dependencies with bun patch when the upstream adapter exposes no option for a needed safety fix" (`c9d8a9f`); + - "Record third-party harness limitations as documented caveats rather than patching dependencies" (`2c83ef3`). +- Trailers are formatted as in `0943098` and `45889b6`: `Assistant-model`, `Assistant-workflow`, one `Assistant-verification: : ` per check, and `Co-authored-by: Alex Lavaee `. +- Comparable harness-adapter durations from the `harness-stack` records: 95m (Pi admit), 92m, 70m, 61m, 30m, 28m (blocked). The median is about 65m over 6 records. Most ran over their estimates. + +--- + +## 2. Atomic SDK 0.9.23 facts + +Paths are under `/home/alexlavaee/.bun/install/global/node_modules/@bastani/atomic/dist/`. + +### Session assembly — `core/sdk.js`, `core/sdk-types.d.ts` + +- **`builtins`:** + - Shipped packages are on unless a key is explicitly `false` (`sdk-types.d.ts:11-14`). + - A custom `resourceLoader` is wrapped in `BuiltinResourceLoader`, which adds the shipped packages whatever the loader's `no*` flags say (`sdk.js:161-164`). + - Intercom is **mandatory**: it is added by `withMandatoryResourceLoader` unless `builtins.intercom === false` (`sdk.js:165-167`, `mandatory-resource-loader.js:11-27`). + - Spike ("loose" mode, with the tools allowlist but default builtins): the active tools stayed limited to the allowlist, but all five builtin extension bundles loaded and ran their startup code, and the run was much slower. So all five keys must be `false`; an allowlist alone is not enough. +- **Tools** (`sdk.js:238-243`, `agent-session-tool-registry.js:17-37,80-85`): + - `tools` omitted: the default coding tools are active, plus every extension tool. Spike "notools" mode listed read, bash, kill, edit, write, find, search, ask_user_question, todo, workflow, subagent, web_search, code_search, fetch_content, get_search_content, intercom and mcp. + - `tools: [...]`: only the named tools are even registered, and **both custom and extension tools must be named**. Every registered allowed tool is re-activated whenever the registry refreshes. + - `noTools: "all"` exposes nothing. + - A custom tool named `intercom` is dropped. +- **Model:** there is no lookup when `model` is passed. When it is `undefined`, `findInitialModel` silently picks a preferred or first-available model (`sdk.js:182-211`). + - Spike: `runtime.getModel("stub","nope")` returned `undefined`. Passing that through produced a session on `stub/stub-model` with `modelFallbackMessage: undefined`. +- **Defaults to override:** + - `fallbackModels` defaults to `settings.fallbackModels`. `SettingsManager.inMemory` makes that `[]`; pass `fallbackModels: []` explicitly anyway. + - `agentDir` defaults to `$ATOMIC_CODING_AGENT_DIR` → `$PI_CODING_AGENT_DIR` → `~/.atomic/agent`. + - Creation always calls `modelRuntime.refresh({allowNetwork:false})`. +- **`systemPromptTransform`** exists but applies only at construction. +- **`createStructuredOutputTool`** runs a second `streamSimple` request (`core/tools/structured-output.js:60-71`). Do not use it; criterion 4 forbids it. + +### Resource loader — `core/resource-loader-reload.js`, `resource-loader-types.d.ts:113-167` + +- The flags `noExtensions`, `noSkills`, `noPromptTemplates`, `noThemes` and `noContextFiles` gate discovery (`reload.js:204-211, 224, 259-261, 276-278, 297-299, 314-322`). +- `additional*Paths` and CLI paths still load when the flags are set. Leave them empty. +- `extensionFactories` load even with `noExtensions`. +- `SYSTEM.md` and `APPEND_SYSTEM.md` are discovered unless `systemPrompt` or `appendSystemPrompt` is given (`reload.js:329-341`). Pass `appendSystemPrompt: []`. +- **Empty system prompt trap:** `systemPrompt: ""` is not nullish, so discovery is skipped, but `buildSystemPromptSections` treats `""` as missing (`system-prompt.js:105-115`). The result is Atomic's default preamble: "expert coding assistant…", the tools list, the guidelines, and an "Atomic documentation" section. + - Spike with `SPIKE_SP=""`: "Atomic documentation" and "expert coding assistant" were both present. + - HarnessAgent turns with no `instructions`, including all three shared contract cases, hit this unless the adapter forces the prompt. +- Even with a custom prompt, Atomic adds ``, `` and `` sections (`system-prompt.js:139-141`). +- **Forcing the prompt:** a `before_agent_start` handler that returns `{systemPrompt}` sets `forceSystemPrompt` (`extensions/runner-events.js:324`). Spike: the model received exactly `"FORCED-TURN-PROMPT"` on every turn. This is the upstream-supported hook for per-turn instructions without reloading resources. + +### Isolation checked in the spike + +- **Setup:** a temporary `HOME` containing `~/.atomic/agent/{AGENTS.md, SYSTEM.md, APPEND_SYSTEM.md, settings.json (defaultTools, fallbackModels), extensions/evil.ts, skills/, prompts/}`, `~/.pi/agent/AGENTS.md` and `~/.agents/skills`. The working directory held `AGENTS.md`, `CLAUDE.md`, `.atomic/extensions`, `.pi/extensions` and `.agents/skills`. +- **Configuration:** `DefaultResourceLoader` with all `no*` flags, `systemPrompt`, `appendSystemPrompt: []` and one inline factory; `SettingsManager.inMemory`; `SessionManager.inMemory(cwd)`; all five builtins `false`; `tools: [host, RESULT]`. +- **Results:** + - The only extension was ``. + - Active and registered tools were exactly the host tool and the result tool. + - No marker text reached the system prompt the stub received. + - **No files were created or modified under the temporary `HOME` or cwd** (`find -newer marker` was empty). + +### Events — spike traces + +| Scenario | Event sequence | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plain turn | `agent_start turn_start message_start/end:system message_start/end:user message_start:assistant message_update:text_start/text_delta/text_end message_end:assistant:stop turn_end agent_end agent_settled` | +| Host tool | `…message_update:toolcall_start/delta/end message_end:assistant:toolUse tool_execution_start:host_a tool_execution_end:host_a message_start/end:toolResult turn_end turn_start …text… agent_end` — two model requests | +| Terminating tool | one model request; `tool_execution_*:RESULT`, then `agent_end` with no second assistant message; the last message is a `toolResult` | +| Blocked result tool on a plain turn | `tool_execution_start/end:RESULT:err`, then the model continues — these parts must be kept out of the stream | +| Hallucinated `bash` | `tool_execution_start/end:bash:err`, "tool not found" handled inside Atomic | +| Abort | `prompt()` **resolved** without throwing; `agent.state.errorMessage = "Request aborted"`; last assistant `stopReason: "aborted"`; `agent_end` still fires | + +- **Abort during a pending host tool:** + - If `execute` honours `signal`, abort settles in about 200 ms with `errorMessage "The operation was aborted."` and `tool_execution_end:err`. + - If `execute` ignores `signal`, **`abort()` and `prompt()` hang** (killed by a 20 s timeout). +- **Other facts:** + - `AgentToolResult` has no `isError`. A throw from `execute` becomes `isError`. + - `tool_call` handler errors **block** execution, which fails closed (`agent-session-tool-hooks.js:13-33`). + - `before_agent_start` handler errors are only reported through `emitError` and **do not fail the turn** (`extensions/runner.js:655`). Fail-closed checks therefore belong in the adapter, not in a throwing hook. +- **Other prompt paths:** + - `prompt()` runs `/…` text as an extension command, and expands skill and template syntax, unless `expandPromptTemplates: false` (`agent-session-prompt.js:93-103, 143-147`). Pass `false`. + - Missing auth throws before any request (`agent-session-prompt.js:187-200`). +- **Oversized results:** results over `DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000` (`core/tools/tool-limits.js:17`) are persisted to a session temp directory. An in-memory `SessionManager` has an empty session dir, so the OS temp dir is used. The model then receives a preview and a path it cannot read (`agent-session-tool-hooks.js:69`, `tools/oversized-tool-result.js:276-313`). Setting `maxResultSizeChars: Infinity` on host `ToolDefinition`s opts out. + +### Settings defaults that matter (`core/settings-manager-basic-accessors.js`) + +| Setting | Default | Effect | +| ------------------------ | ----------------- | --------------------------------------------- | +| `compaction.enabled` | `true` | Extra summarization model calls | +| `retry.enabled` | `true`, 3 retries | Automatic retries | +| `cacheWarming` | `"streaming"` | Extra cache-refresh requests to the provider | +| `sessionSummary.enabled` | `true` | Skipped in SDK "print" mode, per the subagent | + +Suggested in-memory settings: `{ compaction: {enabled:false}, cacheWarming: "off", sessionSummary: {enabled:false} }`, plus explicit `fallbackModels: []`. Keeping `retry` on is a judgement call. + +### Auth — `core/model-runtime.js:86-117`, `core/auth-storage*.js`, `@bastani/pi-ai/dist/env-api-keys.js` + +- `ModelRuntime.create({ credentials?, authPath?, modelsPath?: string|null, refreshOnCreate?, allowModelNetwork? })` has no `providers` option. Register custom providers with `runtime.registerProvider(id, {baseUrl, apiKey, api:"openai-completions", models:[…]})`; this worked in the spike. +- **Default credentials** are file-backed and layered: `~/.atomic/agent/auth.json` and `~/.pi/agent/auth.json`. `FileAuthStorageBackend.withLock` makes the parent directory. OAuth and key updates write `auth.json` atomically (`auth-storage.js:30-122`, `auth-storage-backends.js:20-80`). `readStoredCredential` also goes through `withLock`. +- **`AuthStorage.inMemory(data)`** never touches disk. `modelsPath: null` avoids `models.json` and `models-store.json`. +- **Environment keys still resolve with in-memory credentials.** Spike with `ANTHROPIC_API_KEY=dummy` and `AI_GATEWAY_API_KEY=dummy2`: `hasConfiguredAuth("anthropic")` and `("vercel-ai-gateway")` were true; without them both were false. +- pi-ai also treats ambient **AWS profile/IAM** credentials (for `amazon-bedrock`) and **gcloud ADC** at `~/.config/gcloud/…` (for `google-vertex`) as configured auth. Those are host-logged-in cloud credentials even in an "env-only" mode. +- `ReadOnlyAuthStorage` exists (`auth-storage.js:123`) but is **not exported** from the package root. `getAgentConfigPaths` and `AuthStorage` are exported (`index.d.ts:14,16`). +- The Harness auth vocabulary is `HarnessV1Authentication = 'auto' | 'ai-gateway' | C | Record` (`@ai-sdk/harness/src/v1/harness-authentication.ts:15-24`). + +--- + +## 3. Implementation guidance + +These are proposals. Where the contract does not fix a choice, it is marked **(decision)**. + +### Files + +- `apps/server/src/harness/atomic/adapter.ts`: `createAtomicAdapter(settings)` returning `HarnessV1 & { shutdown(): Promise }`, with `harnessId: "atomic"`, `builtinTools: {}` and `supportsBuiltinToolFiltering: true`. +- An optional `apps/server/src/harness/atomic/auth.ts` for mode handling. +- `apps/server/src/harness/atomic/adapter.test.ts`. +- `apps/server/src/harness/atomic.contract.test.ts`, importing `../pi/model-stub` or `./pi/model-stub`. +- `harnesses.ts`: `atomic: createAtomicHarness`, forwarding only `auth`, plus an `atomic` branch in `harnessFor`. Also update `harnesses.test.ts`. +- `package.json` catalog and `apps/server/package.json`; regenerate `bun.lock`. +- Optional: `config.ts` and `config.test.ts` to require `MODEL` for `atomic`; an `.oxlintrc.json` import restriction. + +### Session creation + +- Create the Atomic session lazily on the first turn, or in `doStart`. Each HarnessV1 session owns one `AgentSession`. +- Options: + - `cwd`: a fresh `mkdtemp` directory removed in `doDestroy`. Do not use the sandbox's virtual `sessionWorkDir` or `process.cwd()`. + - `agentDir`: that same empty temp directory. + - `modelRuntime`: built per the auth mode. + - `model`: the resolved model; `thinkingLevel: "off"` or a setting **(decision)**. + - `fallbackModels: []`. + - `sessionManager: SessionManager.inMemory(cwd)`; `settingsManager: SettingsManager.inMemory({...})` per §2. + - `builtins: { workflows:false, subagents:false, mcp:false, "web-access":false, intercom:false }`. + - `tools: [...hostNames, RESULT_TOOL]`. + - `customTools`: host tools defined with `defineTool`, `Type.Unsafe(spec.inputSchema ?? {type:"object"})` and `maxResultSizeChars: Infinity`. +- The loader is `new DefaultResourceLoader({ cwd, agentDir, settingsManager: SettingsManager.inMemory({}), noExtensions, noSkills, noPromptTemplates, noThemes, noContextFiles: true, systemPrompt: , appendSystemPrompt: [], extensionFactories: [resultToolExtension(state)] })`. +- **Rebuild or refuse** **(decision):** if a later turn changes the host tool set, the session must be rebuilt, because only allowlisted tools are registered. Pi rebuilds on a tool-signature change; Copilot uses a fresh session per turn. Model changes can use `session.setModel` after checking. + +### Fail-closed checks + +Before every `prompt()`, and throw before any model request if a check fails: + +- `created.extensionsResult.extensions` must be exactly the one inline factory. +- The names from `session.getAllTools()` must equal `host ∪ {RESULT}`. +- After `setActiveToolsByName(expected)`, `getActiveToolNames()` must equal `expected`, where `expected = structured ? [...host, RESULT] : host`. +- `session.model` must match the requested provider and id. +- Log `[agent] N tools: …` like Copilot does. + +During the turn: treat `tool_execution_start` for a name outside `host ∪ {RESULT}` as a boundary event. **(decision):** either abort with an `error` part, or let Atomic's "tool not found" stand and never emit it. The spike shows Atomic already refuses unknown names without running anything, and emitting it would make `chat/service.ts` abort the turn anyway. + +### Per-turn prompt and structured output + +- The result extension, following `piResultToolExtension`, uses `on("before_agent_start")` to: + 1. `setActiveTools(expected)`; + 2. return `{ systemPrompt: }`. +- `on("tool_call")` blocks `RESULT` unless `state.structured`. +- The adapter sets `state.structured` from `turn.responseFormat?.type === "json"`, never from prompt text. It reserves the `RESULT` name against host tools, as Pi and Copilot do. +- `RESULT.execute(id, params)` captures `params` in adapter state and returns `terminate: true`. +- On `agent_end`, if a result was captured, emit `text-start`/`text-delta(JSON.stringify(params))`/`text-end`, then `finish-step` and `finish`. If not, reject `done` with "Atomic turn ended without a structured result." +- In JSON mode, drop prose and reasoning, as both existing adapters do. +- The spike confirmed one model request for a structured turn. Keep the Pi-style result-tool schema, `Type.Object({}, {additionalProperties:true})` with the schema written into the instruction, or use `Type.Unsafe(responseFormat.schema)` with a rebuild when the schema changes **(decision)**. Either way HarnessAgent validates host-side. +- Workers need a `web_search` round trip inside a structured turn to keep working. The name `web_search` no longer collides with anything once `web-access` is off. + +### Stream translation (only what `translate()` and HarnessAgent consume) + +- `message_update` with `text_start`/`text_delta`/`text_end` on assistant messages maps to `text-start`/`text-delta`/`text-end` with a stable id per content block. `thinking_*` maps to `reasoning-*` (optional). Ignore `system`, `user` and `toolResult` messages. +- **Host tool round trip:** inside `execute`: + 1. emit `tool-call {toolCallId, toolName, input: JSON.stringify(params)}`; + 2. park a resolver, and race it against `signal`, **mandatory** per the spike; + 3. on `submitToolResult`, emit `tool-result {…, result, isError}`; + 4. return `{content:[{type:"text", text: serialize(output)}]}`, or throw when `isError`. + Never emit parts for `RESULT` or for blocked or unknown tools. +- At the end of `prompt()`, inspect the last assistant message: + - `stopReason: "aborted"`, or the abort signal is set: settle quietly or with an abort error. The HarnessAgent `settleFailure` path turns it into an `abort` part, and `hasUnfinishedTurn` is false. + - `stopReason: "error"` or `agent.state.errorMessage` set: emit an `error` part and reject `done`. + - Otherwise emit `finish-step` and `finish` with `stop`. Usage can come from the assistant message's `usage` or be zeroed as Copilot does. + - `prompt()` preflight throws, such as missing auth, reject `done`. +- **Abort:** + - `turn.abortSignal` calls `session.abort()`; + - settle every pending resolver with an error; + - bound the wait as Copilot's `bounded()` does; + - ignore late `submitToolResult` calls. + +### Remaining lifecycle + +- **`doContinueTurn`:** the in-process equivalent of Pi's live-turn path (§4): if a turn is active, re-point `emit` and return the live control. Otherwise **(decision)**: throw `HarnessCapabilityUnsupportedError`, or run `session.agent.continue()`. `agent` is reachable, but `continue()` is not part of the public session surface. +- **`doStop` / `doDetach`:** tear down and return `{type:"resume-session", harnessId:"atomic", specificationVersion:"harness-v1", data:{}}`, with `isResume: false`. The in-memory session cannot resume across processes, so document that as a caveat. +- **`doSuspendTurn`:** a lossy `continue-turn` or unsupported **(decision)**. +- **`doCompact`:** unsupported, since compaction is off. +- **`doDestroy`:** idempotent — abort, settle pending tools, `session.dispose()`, remove the temp dir. +- **`shutdown()`:** dispose live sessions or no-op. There is no global Atomic runtime unless one shared `ModelRuntime` is kept. +- **`skills`:** Chopin always passes `[]`. Rejecting non-empty skills keeps the "no skills" guarantee explicit **(decision)**. + +### `HARNESS_AUTH` for `atomic` (decision; evidence above) + +Proposal, following the Pi pattern and the `HarnessV1Authentication` vocabulary. `HARNESS_AUTH` is required; unknown values are refused. + +| Mode | Credentials | Allowed bind | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `auto` | A snapshot of the host Atomic login loaded into `AuthStorage.inMemory(...)` — not the file-backed store, so a refresh cannot write to disk — plus environment keys | Loopback only | +| `ai-gateway` | `AuthStorage.inMemory()`, `modelsPath: null`, `AI_GATEWAY_API_KEY`; resolved models should be restricted to provider `vercel-ai-gateway`, because other env keys and ambient AWS/GCP credentials would otherwise be picked up | Any | +| env-key mode (optional, e.g. `direct`) | In-memory credentials with named provider environment keys | Any only if `amazon-bedrock`/`google-vertex` ambient credentials are refused; otherwise loopback | + +- Reading the host login snapshot without writes means reading the JSON from `getAgentConfigPaths("auth.json")` yourself. `AuthStorage.create` and `readStoredCredential` go through `withLock`: that makes a directory and a lock file but writes no credentials. +- Refresh-token rotation that stays in memory may invalidate the host CLI's stored refresh token. Record that as a caveat. +- `api_key` entries using `!command` run a shell command when resolved. Caveat for `auto`. + +### Model resolution (criterion 3) + +- Take `turn.model`, falling back to an adapter `settings.model` for tests. +- Require `provider/id` and call `runtime.getModel(provider, id)`. If it is missing, throw `Atomic does not recognize model ` **before** any request, matching Pi's wording. +- Never pass `undefined` to `createAgentSession`, because that is exactly the silent fallback. +- When no model is given at all: fail **(decision)**. The contract suite needs the stub adapter to set a default model. + +--- + +## 4. Testing and verification + +### `atomic.contract.test.ts` + +- Stub script: + - `"tools"` → a call to `first_host`, then `"Done."`; + - `"output"`/`"rogue"` → a call to `RESULT` with `{"answer":"yes"}`; + - `"abort"` → hold; + - anything else → text. +- Adapter setup: `auth` set to an isolated in-memory configuration, plus a registered `stub` provider (`apiKey:"stub-key"`, `api:"openai-completions"`) and `model: "stub/stub-model"`. +- Cases: + - `harnessContract("atomic", …, createJustBashNetworkSandboxSession)`; + - a plain text turn; + - a host round trip, asserting two requests with `hasPriorToolResult` `[false, true]` and `tool-call`/`tool-result` for `first_host`; + - one request for a structured turn, with `RESULT` offered; + - `RESULT` not offered on a plain turn; + - rogue `RESULT` blocked and hidden; + - quoted instruction text does not enable `RESULT`; + - host `AGENTS.md`, `SYSTEM.md`, `APPEND_SYSTEM.md` and skills stay out of the `system` the stub saw, using a temporary cwd or `HOME` and agent dir; + - an unknown model rejects with zero stub requests; + - the tool boundary: the active and registered tool names equal the host tools, and the system prompt contains no "Atomic documentation" or "expert coding assistant" text on a turn without instructions; + - abort during a pending host tool completes. + +### `atomic/adapter.test.ts` + +Unit tests in the style of `pi/adapter.test.ts`, using a fake extension API or a fake session: + +- result-tool state switching; +- the stream-suppression helper; +- `done` rejections: no result, invalid result, error `stopReason`; +- the reserved result-tool name; +- the auth-mode and settings mapping. + +### `harnesses.test.ts` + +- the map keys include `atomic`; +- missing, unknown and loopback cases for each Atomic mode. + +### Gates + +Run in this order, recording one `Assistant-verification` line per command: + +1. `bun run fix`, then inspect the diff; +2. `bun test`, with `bun test apps/server/src/harness` first for speed; +3. `bun run types`; +4. `bun run ci`. + +Past records show about 1,566 passing tests plus 2 PostgreSQL skips as the baseline. E2E is not required, but the Docker job in CI will pick up the larger dependency tree; `docker build` locally is optional. + +--- + +## 5. Documentation to update + +- **`docs/hosted-agent.md`:** + - lines 13-19: the harness list; + - lines 21-36: ownership — under `atomic`, the owner supplies only the GitHub token and model access comes from `HARNESS_AUTH`; + - lines 46-67: runtime isolation — builtins off, inert loader, forced system prompt, fail-closed tool check; + - the code map near line 209. +- **`docs/self-hosting.md`:** + - lines 56-122, "Choose and trust a harness": the Atomic auth modes and loopback rule, `MODEL` as `provider/id`, the result tool, caveats; + - line 131 onward: prerequisites; + - the environment table rows for `MODEL`, `HARNESS`, `HARNESS_AUTH` and `SERVER_HOST`; + - the sample environment and startup checks. +- **Also:** `.env.example` lines 5-17, `docs/architecture.md` (lines 6, 36, 56, 112-124, 473-477 name "Copilot or Pi"), and `README.md` lines 73-84 and 104. +- **AGENTS.md** does not list harnesses by name. The "Harness and Planner" failure traps (lines 280-299) should gain an Atomic entry: the empty-prompt preamble, abort-signal hangs, and `prompt()` resolving on abort. + +**Atomic caveats to document:** + +- the silent model fallback, which the adapter guards; +- the default coding-agent preamble when no prompt is given; +- the mandatory Intercom unless disabled; +- the 50 KB tool-result spill to temp files; +- environment and ambient cloud credentials are read even in isolated mode; +- `auto` refresh stays in memory and can rotate the host token; +- no resume across processes; +- no Copilot-style per-session credit limit, so workers' `maxAiCredits` is ignored; +- the package size; +- `typebox` 1.3.7 vs 1.3.27. + +## 6. Open items and limits + +- I did not verify `tsgo` compatibility of Chopin's `typebox` 1.3.7 schemas with Atomic's `ToolDefinition`, or that `bun install` resolves 0.9.23 cleanly in this workspace. +- I did not trace the exact OAuth refresh call site that writes `auth.json` in the file-backed mode. +- I did not observe how HarnessAgent reacts to a `tool-call` emitted from inside `execute` while other parallel calls in the same batch are pending. Atomic schedules "shared" tool calls concurrently; add a test with two host calls in one message. +- I did not test `session.agent.continue()` for `doContinueTurn`. +- I did not write the research-codebase skill's `research/docs/` file, to keep the repository untouched for the implementation commit. This message is the artifact. From 4cfcdd1d056f12c1f19ca1a719a48cf672abda61 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 13:57:49 -0700 Subject: [PATCH 03/17] Name the legacy Pi agent directory for Atomic auto auth Atomic reads PI_CODING_AGENT_DIR when ATOMIC_CODING_AGENT_DIR is unset, so the auto-mode auth.json lookup lists both variables in precedence order. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: source check passed: @bastani/atomic 0.9.23 dist/config.js getEnvValue prefers ATOMIC_CODING_AGENT_DIR, then PI_CODING_AGENT_DIR, and a set variable disables the ~/.pi fallback Assistant-verification: dprint ci passed: bun run ci Co-authored-by: Alex Lavaee --- docs/self-hosting.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index e51eb5bd..248c0b53 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -127,7 +127,8 @@ enforces. - `auto` copies the host operator's Atomic login into memory when the first turn starts: `$ATOMIC_CODING_AGENT_DIR/auth.json` when that variable is set, - otherwise `~/.atomic/agent/auth.json` over the legacy `~/.pi/agent/auth.json`. + then `$PI_CODING_AGENT_DIR/auth.json` for the legacy variable, otherwise + `~/.atomic/agent/auth.json` over the legacy `~/.pi/agent/auth.json`. It also resolves provider keys from the process environment, such as `ANTHROPIC_API_KEY`, and ambient cloud credentials, such as an AWS profile or Google application default credentials. Because every admitted writer's turns From 7544ed030aa0621573926e1c40f7031aa106ec72 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 22:44:15 -0700 Subject: [PATCH 04/17] Upgrade the Atomic harness to 0.9.24-alpha.1 The release keeps the same five builtin packages, so BUILTINS_OFF still covers every one and the Planner session stays isolated to Chopin's host tools. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: 1688 pass, 2 PostgreSQL skips, 0 fail, including the atomic contract and isolation suites Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run ci passed: dprint, oxlint, tokens, type scale, Impeccable baseline Assistant-verification: source check passed: @bastani/atomic 0.9.24-alpha.1 dist/builtin lists intercom, mcp, subagents, web-access, workflows, matching BUILTINS_OFF Co-authored-by: Alex Lavaee --- bun.lock | 30 +++++++++++++++++------------- docs/self-hosting.md | 2 +- package.json | 2 +- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index ccdb8191..9b28a173 100644 --- a/bun.lock +++ b/bun.lock @@ -230,7 +230,7 @@ "@ai-sdk/harness-pi": "1.0.128", "@ai-sdk/mcp": "2.0.60", "@ai-sdk/sandbox-just-bash": "1.0.126", - "@bastani/atomic": "0.9.23", + "@bastani/atomic": "0.9.24-alpha.1", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", @@ -382,27 +382,27 @@ "@base-ui/utils": ["@base-ui/utils@0.3.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ=="], - "@bastani/atomic": ["@bastani/atomic@0.9.23", "", { "dependencies": { "@bastani/atomic-natives": "0.9.23", "@bastani/pi-ai": "0.9.23", "@dbos-inc/dbos-sdk": "4.25.14", "@earendil-works/pi-agent-core": "0.87.1", "@earendil-works/pi-client": "0.87.1", "@earendil-works/pi-protocol": "0.87.1", "@earendil-works/pi-tui": "0.87.1", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "@typesafe-ai/sdk": "0.6.0", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "embedded-postgres": "18.4.0-beta.17", "glob": "13.0.6", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "linkedom": "^0.18.13", "lru-cache": "11.5.3", "markit-ai": "0.5.3", "minimatch": "10.2.6", "open": "^11.0.1", "p-limit": "^7.3.2", "pg": "8.22.0", "proper-lockfile": "4.1.2", "semver": "7.8.5", "turndown": "^7.2.0", "typebox": "1.3.27", "undici": "8.10.2", "unpdf": "1.8.1", "yaml": "2.9.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "atomic": "dist/cli.js" } }, "sha512-h859zFfHSrxDZMalN0+6mkIcR0dXm99koTTkX7zYjj0Sdkl0uHyQZKsh7Eqqn7BIIJRxVqqRaejOhKjxqlaSlQ=="], + "@bastani/atomic": ["@bastani/atomic@0.9.24-alpha.1", "", { "dependencies": { "@bastani/atomic-natives": "0.9.24-alpha.1", "@bastani/pi-ai": "0.9.24-alpha.1", "@dbos-inc/dbos-sdk": "4.25.14", "@earendil-works/pi-agent-core": "0.87.1", "@earendil-works/pi-client": "0.87.1", "@earendil-works/pi-protocol": "0.87.1", "@earendil-works/pi-tui": "0.87.1", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "@typesafe-ai/sdk": "0.6.0", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "embedded-postgres": "18.4.0-beta.17", "glob": "13.0.6", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "linkedom": "^0.18.13", "lru-cache": "11.5.3", "markit-ai": "0.5.3", "minimatch": "10.2.6", "open": "^11.0.1", "p-limit": "^7.3.2", "pg": "8.23.0", "proper-lockfile": "4.1.2", "semver": "7.8.5", "turndown": "^7.2.0", "typebox": "1.3.27", "undici": "8.10.2", "unpdf": "1.8.1", "yaml": "2.9.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "atomic": "dist/cli.js" } }, "sha512-KYrHqDsUEhyZ8rXg3kvWPeV8X44/Gf7W/cIXXP3q3K27MmKNyqiQ8F2y0ssDvY4y4ZjMxzUv52HledSnsFTq2w=="], - "@bastani/atomic-natives": ["@bastani/atomic-natives@0.9.23", "", { "optionalDependencies": { "@bastani/atomic-natives-darwin-arm64": "0.9.23", "@bastani/atomic-natives-darwin-x64": "0.9.23", "@bastani/atomic-natives-linux-arm64-gnu": "0.9.23", "@bastani/atomic-natives-linux-arm64-musl": "0.9.23", "@bastani/atomic-natives-linux-x64-gnu": "0.9.23", "@bastani/atomic-natives-linux-x64-musl": "0.9.23", "@bastani/atomic-natives-win32-arm64-msvc": "0.9.23", "@bastani/atomic-natives-win32-x64-msvc": "0.9.23" } }, "sha512-zd4w/nEIc0hDlZWkmCCncG7/befEHtXo4uxIXNVNlKzTzdAgdUdq6VcMutvVsXK8v2kMGvHNdLkQO6hF/l050Q=="], + "@bastani/atomic-natives": ["@bastani/atomic-natives@0.9.24-alpha.1", "", { "optionalDependencies": { "@bastani/atomic-natives-darwin-arm64": "0.9.24-alpha.1", "@bastani/atomic-natives-darwin-x64": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-arm64-gnu": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-arm64-musl": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-x64-gnu": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-x64-musl": "0.9.24-alpha.1", "@bastani/atomic-natives-win32-arm64-msvc": "0.9.24-alpha.1", "@bastani/atomic-natives-win32-x64-msvc": "0.9.24-alpha.1" } }, "sha512-jaQHzlzBcFoyVR5jj+bpaKgoCz8BzrfeISk+PExXHUjGHpUH98M9VtTErk6ytRogV1YP8Z53jdobqpX87Gi95g=="], - "@bastani/atomic-natives-darwin-arm64": ["@bastani/atomic-natives-darwin-arm64@0.9.23", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v0PjvsXlWEn4nTaVjJ/TvMHg0hRJlNPkQ6ET+kcVP/m8IHLfzqa0w9pFKVV6Ez0lymQwQbFr2OUbe4zEBf7jvw=="], + "@bastani/atomic-natives-darwin-arm64": ["@bastani/atomic-natives-darwin-arm64@0.9.24-alpha.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hntKcTgjFHDzotDSu8czfI3XL0rjv8ZTmmGRqaJhGDbW7VKpIkZ5khOEl/A86Uu89tsT09Kw9M1WZxO1YalzJw=="], - "@bastani/atomic-natives-darwin-x64": ["@bastani/atomic-natives-darwin-x64@0.9.23", "", { "os": "darwin", "cpu": "x64" }, "sha512-lOK1Eq0WQSj/PKesTayqdr1e/aOfE/vth81yvUfdt2AyI4QyrTJuVV4q72jBWEVhkLhhy4ql/6rDwEYGby5QVA=="], + "@bastani/atomic-natives-darwin-x64": ["@bastani/atomic-natives-darwin-x64@0.9.24-alpha.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/LC/wALhgmj/Xuf9IFW+R4OZLrQqwBUlHHmd9cH/3KQsSXUS1jeAL+cd5BwmPAP9ugRxGHP4GodP5JnnFhi61A=="], - "@bastani/atomic-natives-linux-arm64-gnu": ["@bastani/atomic-natives-linux-arm64-gnu@0.9.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-fZ6qz1FvALylPrQCtuMEn0jH1PRLywpvdSPXEqMqFRFx7ST1DFjatSUBHNb3pv0nj52jDDTVOLZUCmjECSv2MQ=="], + "@bastani/atomic-natives-linux-arm64-gnu": ["@bastani/atomic-natives-linux-arm64-gnu@0.9.24-alpha.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3g0gt/oXQsNKIC0IaQlJwCP86xiXi76lIl6McxpoNM1vHX+o5BOCIX0Ah6qtdlsSc2iuafImWy9FRqFRwTbzCA=="], - "@bastani/atomic-natives-linux-arm64-musl": ["@bastani/atomic-natives-linux-arm64-musl@0.9.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-cxAraZvtxuzi+qB8stflP/v5F0zOo7kFcbrYbzPRHS+uL6Uc+gAVeLJYsrTaWuTH+aN6HVpwXn+Q+4F8KU6l1Q=="], + "@bastani/atomic-natives-linux-arm64-musl": ["@bastani/atomic-natives-linux-arm64-musl@0.9.24-alpha.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3rJDvT9chlGTSuLLOT/pSFrYbmv+UmTMUu1NOi16nHz9UyoCZiida9EGyzkSVByt9Py+RnqXaCk+Yr2iW4jn8w=="], - "@bastani/atomic-natives-linux-x64-gnu": ["@bastani/atomic-natives-linux-x64-gnu@0.9.23", "", { "os": "linux", "cpu": "x64" }, "sha512-7gwkDf4PCVEi2pRPIouxcFSdNnwACd5U1lvNle8/V2utcGRz/NlKnqxG+Ic8jYUafyzPgpS9xshUQoDIVo1KSw=="], + "@bastani/atomic-natives-linux-x64-gnu": ["@bastani/atomic-natives-linux-x64-gnu@0.9.24-alpha.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eyd7fc1oQPZLIF1eJwvf73QsgAcAYtXfJ1VoE1fOPcP3jWWRusbItuBi2lFWMSP/8jacQDxDJh0jWsh3pyEuyg=="], - "@bastani/atomic-natives-linux-x64-musl": ["@bastani/atomic-natives-linux-x64-musl@0.9.23", "", { "os": "linux", "cpu": "x64" }, "sha512-/KUQPXbVKJ1/jWQ9BjN0LqZE8j4rsIIZKHwy6LEpJ83eoHOwjJsHf9o2LPTRl3cEHHPECxME21ySJ/TTl1PIuw=="], + "@bastani/atomic-natives-linux-x64-musl": ["@bastani/atomic-natives-linux-x64-musl@0.9.24-alpha.1", "", { "os": "linux", "cpu": "x64" }, "sha512-45AKuQIx7jWDAN5YpJLiiFg/eH6pkZ12SRs8kBDOhHfE8BBnJM+Cgs40CS61uoY1Z4VqDx+yHrQ0e33euUVaqw=="], - "@bastani/atomic-natives-win32-arm64-msvc": ["@bastani/atomic-natives-win32-arm64-msvc@0.9.23", "", { "os": "win32", "cpu": "arm64" }, "sha512-ha0u9yelInx7F/5HKXArjLRWbKCqJDvx+Zl30JSTZoikL4GNKmZbVzrD20ZKT9N7XMZezbAb3SzElmeFElU82Q=="], + "@bastani/atomic-natives-win32-arm64-msvc": ["@bastani/atomic-natives-win32-arm64-msvc@0.9.24-alpha.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-plfCBL5uaNEHH/LKlu7dc+DAA8uoh1PUvOUGopOgaP1pWeJAjGurB09zy1wCTBboHzaIAPT3GIF6wPyFBVDVlw=="], - "@bastani/atomic-natives-win32-x64-msvc": ["@bastani/atomic-natives-win32-x64-msvc@0.9.23", "", { "os": "win32", "cpu": "x64" }, "sha512-YMsQXBj5KHTK+dDwuTTzoQyi1kmKGEBlP8HMZeQ746CJZuoC8ekw2yzM5xogwudPEJw6KBtbqmO9lw0B7y0VXA=="], + "@bastani/atomic-natives-win32-x64-msvc": ["@bastani/atomic-natives-win32-x64-msvc@0.9.24-alpha.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ODEXhle6rXJiBSH8wtjTmN0xJ5edpU76SvEbcdmCLpHJ92j7oWBg8fMTAninkjcX5pl8Kw5c87RK35GsFKuUWA=="], - "@bastani/pi-ai": ["@bastani/pi-ai@0.9.23", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Z7s6dEeuQRj08avCqltxTcf+LRTYek1TZ3/tbSJxH8WELH/yNOzkIzcuSpcYzOZ7hrWFzwAB1AaKDITSgUSwpA=="], + "@bastani/pi-ai": ["@bastani/pi-ai@0.9.24-alpha.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-hRsS16AcvOvI/SEZhAMYfnPIQf4ycxuFQJb7AjzK3BuosKnHJudjLjKR+ah9+C4O87IzwPpZSChVTfsAv7/Z4w=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], @@ -2030,7 +2030,7 @@ "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], @@ -2460,6 +2460,8 @@ "@dbos-inc/dbos-sdk/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "@dbos-inc/dbos-sdk/pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + "@dbos-inc/dbos-sdk/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "@earendil-works/chord/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], @@ -2540,6 +2542,8 @@ "ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "embedded-postgres/pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 248c0b53..99a5ecef 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -115,7 +115,7 @@ it. The Pi contract suite (`apps/server/src/harness/pi.contract.test.ts`) covers this against the real Pi agent loop; run it before bumping `@ai-sdk/harness-pi` or `@earendil-works/pi-coding-agent`. -`HARNESS=atomic` runs Atomic 0.9.23 in the Chopin server process through its +`HARNESS=atomic` runs Atomic 0.9.24-alpha.1 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use RPC mode, or substitute Atomic for Pi's runtime. Each harness session owns one in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see diff --git a/package.json b/package.json index 84ed5f7f..fff3906c 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@ai-sdk/harness-pi": "1.0.128", "@ai-sdk/mcp": "2.0.60", "@ai-sdk/sandbox-just-bash": "1.0.126", - "@bastani/atomic": "0.9.23", + "@bastani/atomic": "0.9.24-alpha.1", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", From 12815c81e0e4ebd87b16d587994f7a0002a4114f Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 08:38:07 -0700 Subject: [PATCH 05/17] Remove the working directory when a session closes during setup Closing an Atomic session while its first turn was still setting up ran cleanup before the working directory existed; setup then created it, saw the session closed, and stopped without deleting it. Delete the directory on that closed path, in a finally so a failed dispose still removes it. Reported by Maggie Appleton in review. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: fail-before/pass-after passed: the new lifecycle test left a chopin-atomic-planner directory behind without the fix and passes with it Assistant-verification: bun test passed: 1824 pass, 2 PostgreSQL skips, 0 fail Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record, Impeccable (no new findings) Co-authored-by: Alex Lavaee --- apps/server/src/harness/atomic.contract.test.ts | 12 ++++++++++++ apps/server/src/harness/atomic/adapter.ts | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/src/harness/atomic.contract.test.ts b/apps/server/src/harness/atomic.contract.test.ts index 202297d0..9a87841c 100644 --- a/apps/server/src/harness/atomic.contract.test.ts +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -208,6 +208,18 @@ describe("atomic HarnessV1 lifecycle", () => { }); } + it("leaves no working directory behind when closed while its first turn is still setting up", async () => { + let planners = async () => + new Set((await readdir(tmpdir())).filter(name => name.startsWith("chopin-atomic-planner-"))); + let before = await planners(); + let session = await start(); + let turn = session.doPromptTurn({ prompt: "plain", skills: [], tools: [], emit: () => {} }); + await session.doDestroy(); + await expect(turn).rejects.toThrow("Atomic session is closed."); + let left = [...await planners()].filter(name => !before.has(name)); + expect(left).toEqual([]); + }); + it("settles a pending host tool on abort and ignores a late result", async () => { let session = await start(); let first = recorder(); diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index 9d37dc9b..f74712f5 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -459,7 +459,15 @@ export function createAtomicAdapter( let session = created.session; let leak = hostLeak(session, created.extensionsResult.extensions.length, hostNames); if (leak || closed) { - await session.dispose(); + try { + await session.dispose(); + } finally { + // Closing during setup ran cleanup before this directory existed. + if (closed && directory) { + await rm(directory, { recursive: true, force: true }); + directory = undefined; + } + } throw leak ? new ToolBoundaryError(`Atomic session is not isolated: ${leak}.`) : new Error("Atomic session is closed."); From 572d3e2ef4b009423027c91f685501bc37c3df9c Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 09:16:20 -0700 Subject: [PATCH 06/17] Upgrade the Atomic harness to 0.9.25 0.9.25 is the stable release that rolls up the 0.9.25 prereleases, including SDK workflow run control and the native MCP client. It ships the same five builtin packages, so BUILTINS_OFF still turns off each one for the isolated Planner. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: 1824 pass, 2 PostgreSQL skips, 0 fail Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record, Impeccable (no new findings) Assistant-verification: source check passed: @bastani/atomic 0.9.25 dist/builtin lists intercom, mcp, subagents, web-access, workflows Co-authored-by: Alex Lavaee --- bun.lock | 48 +++++++++++++++++++++++--------------------- docs/self-hosting.md | 2 +- package.json | 2 +- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/bun.lock b/bun.lock index 9b28a173..c83d381c 100644 --- a/bun.lock +++ b/bun.lock @@ -230,7 +230,7 @@ "@ai-sdk/harness-pi": "1.0.128", "@ai-sdk/mcp": "2.0.60", "@ai-sdk/sandbox-just-bash": "1.0.126", - "@bastani/atomic": "0.9.24-alpha.1", + "@bastani/atomic": "0.9.25", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", @@ -382,27 +382,25 @@ "@base-ui/utils": ["@base-ui/utils@0.3.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ=="], - "@bastani/atomic": ["@bastani/atomic@0.9.24-alpha.1", "", { "dependencies": { "@bastani/atomic-natives": "0.9.24-alpha.1", "@bastani/pi-ai": "0.9.24-alpha.1", "@dbos-inc/dbos-sdk": "4.25.14", "@earendil-works/pi-agent-core": "0.87.1", "@earendil-works/pi-client": "0.87.1", "@earendil-works/pi-protocol": "0.87.1", "@earendil-works/pi-tui": "0.87.1", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "@typesafe-ai/sdk": "0.6.0", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "embedded-postgres": "18.4.0-beta.17", "glob": "13.0.6", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "linkedom": "^0.18.13", "lru-cache": "11.5.3", "markit-ai": "0.5.3", "minimatch": "10.2.6", "open": "^11.0.1", "p-limit": "^7.3.2", "pg": "8.23.0", "proper-lockfile": "4.1.2", "semver": "7.8.5", "turndown": "^7.2.0", "typebox": "1.3.27", "undici": "8.10.2", "unpdf": "1.8.1", "yaml": "2.9.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "atomic": "dist/cli.js" } }, "sha512-KYrHqDsUEhyZ8rXg3kvWPeV8X44/Gf7W/cIXXP3q3K27MmKNyqiQ8F2y0ssDvY4y4ZjMxzUv52HledSnsFTq2w=="], + "@bastani/atomic": ["@bastani/atomic@0.9.25", "", { "dependencies": { "@bastani/atomic-natives": "0.9.25", "@bastani/pi-ai": "0.9.25", "@dbos-inc/dbos-sdk": "4.25.14", "@earendil-works/pi-agent-core": "0.99.2", "@earendil-works/pi-client": "0.99.2", "@earendil-works/pi-codemode": "0.99.2", "@earendil-works/pi-protocol": "0.99.2", "@earendil-works/pi-tui": "0.99.2", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "@typesafe-ai/sdk": "0.6.0", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "embedded-postgres": "18.4.0-beta.17", "glob": "13.0.6", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "linkedom": "^0.18.13", "lru-cache": "11.5.3", "markit-ai": "0.5.3", "minimatch": "10.2.6", "open": "^11.0.1", "p-limit": "^7.3.2", "pg": "8.23.0", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "turndown": "^7.2.0", "typebox": "1.3.27", "undici": "8.10.2", "unpdf": "1.8.1", "yaml": "2.9.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "atomic": "dist/cli.js" } }, "sha512-yj/MX60cSZ2QIN+4Lmxgt7S0a/Ol06ZpH7tHsflVC36hzVl2K5a+AXWjd5KwnwGlP1ZJlvd3yr+pw+/9bwmBlw=="], - "@bastani/atomic-natives": ["@bastani/atomic-natives@0.9.24-alpha.1", "", { "optionalDependencies": { "@bastani/atomic-natives-darwin-arm64": "0.9.24-alpha.1", "@bastani/atomic-natives-darwin-x64": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-arm64-gnu": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-arm64-musl": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-x64-gnu": "0.9.24-alpha.1", "@bastani/atomic-natives-linux-x64-musl": "0.9.24-alpha.1", "@bastani/atomic-natives-win32-arm64-msvc": "0.9.24-alpha.1", "@bastani/atomic-natives-win32-x64-msvc": "0.9.24-alpha.1" } }, "sha512-jaQHzlzBcFoyVR5jj+bpaKgoCz8BzrfeISk+PExXHUjGHpUH98M9VtTErk6ytRogV1YP8Z53jdobqpX87Gi95g=="], + "@bastani/atomic-natives": ["@bastani/atomic-natives@0.9.25", "", { "optionalDependencies": { "@bastani/atomic-natives-darwin-arm64": "0.9.25", "@bastani/atomic-natives-darwin-x64": "0.9.25", "@bastani/atomic-natives-linux-arm64-gnu": "0.9.25", "@bastani/atomic-natives-linux-arm64-musl": "0.9.25", "@bastani/atomic-natives-linux-x64-gnu": "0.9.25", "@bastani/atomic-natives-linux-x64-musl": "0.9.25", "@bastani/atomic-natives-win32-arm64-msvc": "0.9.25", "@bastani/atomic-natives-win32-x64-msvc": "0.9.25" } }, "sha512-M2OipXIzjIZJ0/DQm28zvzlI2wJ8poWBgCe9UKMLV2tBKItzxvIp85hEjUVir8ZbTOlAKpeAC83laTVmdaQ2FQ=="], - "@bastani/atomic-natives-darwin-arm64": ["@bastani/atomic-natives-darwin-arm64@0.9.24-alpha.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hntKcTgjFHDzotDSu8czfI3XL0rjv8ZTmmGRqaJhGDbW7VKpIkZ5khOEl/A86Uu89tsT09Kw9M1WZxO1YalzJw=="], + "@bastani/atomic-natives-darwin-arm64": ["@bastani/atomic-natives-darwin-arm64@0.9.25", "", { "os": "darwin", "cpu": "arm64" }, "sha512-puuAEWVuQzpttskalJ3/LWb4gBp8yrWK+CFaSm7kDyJ7Y9XOmG90+xDpFXfT8uw9FkpsD9Ucjnh9/pBuBFzq+A=="], - "@bastani/atomic-natives-darwin-x64": ["@bastani/atomic-natives-darwin-x64@0.9.24-alpha.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/LC/wALhgmj/Xuf9IFW+R4OZLrQqwBUlHHmd9cH/3KQsSXUS1jeAL+cd5BwmPAP9ugRxGHP4GodP5JnnFhi61A=="], + "@bastani/atomic-natives-darwin-x64": ["@bastani/atomic-natives-darwin-x64@0.9.25", "", { "os": "darwin", "cpu": "x64" }, "sha512-YrhDtamBaWwzyMmslN9lSpPPOy7H2rcKE7xDa7akY5Fwxj7D7WwqsDHHAK0Qm8BIXkh1Ca4biaNh3FXMHtoLsw=="], - "@bastani/atomic-natives-linux-arm64-gnu": ["@bastani/atomic-natives-linux-arm64-gnu@0.9.24-alpha.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3g0gt/oXQsNKIC0IaQlJwCP86xiXi76lIl6McxpoNM1vHX+o5BOCIX0Ah6qtdlsSc2iuafImWy9FRqFRwTbzCA=="], + "@bastani/atomic-natives-linux-arm64-gnu": ["@bastani/atomic-natives-linux-arm64-gnu@0.9.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZGj95QTWjp14qtXyeL+qO5pF4LA7E+n7noacBOrQ9ryjc92YMCr5Z1e6NRm9Gmxc1uvb56LkxOGSgblR2jMUfA=="], - "@bastani/atomic-natives-linux-arm64-musl": ["@bastani/atomic-natives-linux-arm64-musl@0.9.24-alpha.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3rJDvT9chlGTSuLLOT/pSFrYbmv+UmTMUu1NOi16nHz9UyoCZiida9EGyzkSVByt9Py+RnqXaCk+Yr2iW4jn8w=="], + "@bastani/atomic-natives-linux-arm64-musl": ["@bastani/atomic-natives-linux-arm64-musl@0.9.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-YeXRi/3avBYfMXu8q5PG3YhXK8LUkMKiyQRnqROnbiDIoF+YqFwxHLrjTfZpDDAd/CIN+Zqt2ySeA6FpVIkQtA=="], - "@bastani/atomic-natives-linux-x64-gnu": ["@bastani/atomic-natives-linux-x64-gnu@0.9.24-alpha.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eyd7fc1oQPZLIF1eJwvf73QsgAcAYtXfJ1VoE1fOPcP3jWWRusbItuBi2lFWMSP/8jacQDxDJh0jWsh3pyEuyg=="], + "@bastani/atomic-natives-linux-x64-gnu": ["@bastani/atomic-natives-linux-x64-gnu@0.9.25", "", { "os": "linux", "cpu": "x64" }, "sha512-cCkTXcQAAv55w8f0ffOZ3A5p18vLu8b6YqjOUhrnNerrBzp+E96QXLQX3dyXqEf/4FVgzLz06DRXCTSZrQ1hXA=="], - "@bastani/atomic-natives-linux-x64-musl": ["@bastani/atomic-natives-linux-x64-musl@0.9.24-alpha.1", "", { "os": "linux", "cpu": "x64" }, "sha512-45AKuQIx7jWDAN5YpJLiiFg/eH6pkZ12SRs8kBDOhHfE8BBnJM+Cgs40CS61uoY1Z4VqDx+yHrQ0e33euUVaqw=="], + "@bastani/atomic-natives-linux-x64-musl": ["@bastani/atomic-natives-linux-x64-musl@0.9.25", "", { "os": "linux", "cpu": "x64" }, "sha512-g2Y7MKUldwdlKmtzb3TZmuqfcsOOLBdoX2GIWZ0mnbEyk8NqpZjGWUgC4gbfF2SvwWW/aSd4PK4jeKkSzpEy0w=="], - "@bastani/atomic-natives-win32-arm64-msvc": ["@bastani/atomic-natives-win32-arm64-msvc@0.9.24-alpha.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-plfCBL5uaNEHH/LKlu7dc+DAA8uoh1PUvOUGopOgaP1pWeJAjGurB09zy1wCTBboHzaIAPT3GIF6wPyFBVDVlw=="], + "@bastani/atomic-natives-win32-arm64-msvc": ["@bastani/atomic-natives-win32-arm64-msvc@0.9.25", "", { "os": "win32", "cpu": "arm64" }, "sha512-SMTJP0zwi/wKVIqRYvEoQX2hHEZ4V8bLJIhaiqsGn6yL7JHl9bw+uGqQ4krLbOZ+8+fPI4zi0ub6N7l+5zOp9Q=="], - "@bastani/atomic-natives-win32-x64-msvc": ["@bastani/atomic-natives-win32-x64-msvc@0.9.24-alpha.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ODEXhle6rXJiBSH8wtjTmN0xJ5edpU76SvEbcdmCLpHJ92j7oWBg8fMTAninkjcX5pl8Kw5c87RK35GsFKuUWA=="], - - "@bastani/pi-ai": ["@bastani/pi-ai@0.9.24-alpha.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-hRsS16AcvOvI/SEZhAMYfnPIQf4ycxuFQJb7AjzK3BuosKnHJudjLjKR+ah9+C4O87IzwPpZSChVTfsAv7/Z4w=="], + "@bastani/pi-ai": ["@bastani/pi-ai@0.9.25", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "0.99.2", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-flPqKzaybHi7Aucvh8qFdhYduYpRVpvkgnRHlOecOhwbtSb4kjdztuhqtll7sr3k/lE0Q/CW0LsTbhEtphyH4g=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], @@ -520,19 +518,21 @@ "@earendil-works/chord": ["@earendil-works/chord@0.85.1", "", { "dependencies": { "esbuild": "0.28.1" } }, "sha512-VDlkEC3dhCzQ5fcyH1OhG19dq+6jCn+rqc/iXFivwDYGR5anwo2RCiXij9PpHhqNR5GuhhE+Er69Zi1Sn4eY6w=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-telemetry": "^0.87.1", "diff": "8.0.4", "ignore": "7.0.8", "typebox": "1.3.27", "yaml": "2.9.0" } }, "sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.99.2", "", { "dependencies": { "@earendil-works/chord": "^0.99.2", "@earendil-works/pi-ai": "^0.99.2", "@earendil-works/pi-telemetry": "^0.99.2", "diff": "8.0.4", "ignore": "7.0.8", "typebox": "1.3.27", "yaml": "2.9.0" } }, "sha512-VX0QMcg8HKBsv1bYP2NwmutBYa44OrwmtqR6Dq7QC3Ln8X/IlpHhUxB5L1JgnXNUUkTm8Wg/HZhzS797NBDMCQ=="], "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.74.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.91.1", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "^2.2.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "openai": "6.26.0", "partial-json": "^0.1.7", "typebox": "^1.1.24" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-ukQBHGDm20k9ZUS2cGjNN9vDJp/48r35xmvgSx3paCaC06r2N/PLuRZoJmwQ1ZM7f8T3072odv9YPWn+77w0LA=="], - "@earendil-works/pi-client": ["@earendil-works/pi-client@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-protocol": "^0.87.1" } }, "sha512-x8dfZ7ppAao9SDnZvD5/DErVdq2fI7hg4lFj7qEhtgo6wimrRafGSLsqLklFVjb6kxU2W3j9F4RZz0rRcb0cwA=="], + "@earendil-works/pi-client": ["@earendil-works/pi-client@0.99.2", "", { "dependencies": { "@earendil-works/chord": "^0.99.2", "@earendil-works/pi-protocol": "^0.99.2" } }, "sha512-j8dc4gOC3ExalNZqJODfmaT3Ei2nQNMFKDe4KbyGSMbByD/fbKXjbNeQLy2H0aG3L9fZJc4bZh+cR10D3XTfQQ=="], + + "@earendil-works/pi-codemode": ["@earendil-works/pi-codemode@0.99.2", "", { "dependencies": { "quickjs-wasi": "3.6.2" } }, "sha512-PVxNGu4l7LI92MvwUbaY3trjznU1Mlp2OMGwHwCWzLsjG7/lDa8xf6ei+Gws7VCt+Ri1tO+nFVl9KV2EeX7HWg=="], "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.85.1", "", { "dependencies": { "@earendil-works/chord": "^0.85.1", "@earendil-works/pi-agent-core": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", "@earendil-works/pi-tui": "^0.85.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.9.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ=="], - "@earendil-works/pi-protocol": ["@earendil-works/pi-protocol@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "typebox": "1.3.27" } }, "sha512-48WVjryAIiKOJXnKgO15h2MT/oeH5uf0S4DykcVj3ZrxMTDSGxcUaS5VtvHtFQ1rZSSIjkVcblE13k0GksBuoQ=="], + "@earendil-works/pi-protocol": ["@earendil-works/pi-protocol@0.99.2", "", { "dependencies": { "@earendil-works/chord": "^0.99.2", "typebox": "1.3.27" } }, "sha512-ry9hfwZy06sEjsQFsQJXptJ9EO5w8wxBUYVNNSo1WwkHVeItu5lTtCBAC80i9oRlTaSb2rXLjuuh+NWILhFw8g=="], - "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.87.1", "", {}, "sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ=="], + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.99.2", "", {}, "sha512-WThYU4XM6jjjzH4FzLdreN7QAPS9SDdokL0W0Ldheg1ssCIJkfpK7PgebFm7/PoQub7OiXx+SRF6B/oF7z7lXA=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.87.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-YEH2vRyOeiO7hhN6j6AE6YwKSq2Kz2f3XR8bj1TbR+aGE/JsnY1hLPMI2pvaZfRM1n9Y00tejxFQ4zbzvF7nkQ=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.99.2", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-IOcNnd390NCIwBEKeOiBK/bvaR7Z+vEsvB5TtKsFdsY/vAZE6qRHbQqt6jpTefTiKPfQlwG8eS1BU42DeG2v4w=="], "@embedded-postgres/darwin-arm64": ["@embedded-postgres/darwin-arm64@18.4.0-beta.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Kg1ZMNFzkVIJs3g4V2UYEc5X005km9rGinxFBH8R1sOU2Rblvz4pt2Uf93Pu8Rk273Ue9HIdrbMPpgUqwjUi0Q=="], @@ -2100,6 +2100,8 @@ "quickjs-emscripten-core": ["quickjs-emscripten-core@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg=="], + "quickjs-wasi": ["quickjs-wasi@3.6.2", "", {}, "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -2466,9 +2468,9 @@ "@earendil-works/chord/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "@earendil-works/pi-agent-core/@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + "@earendil-works/pi-agent-core/@earendil-works/chord": ["@earendil-works/chord@0.99.2", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-2dJWiNsOJm/ioBl1fhpJmw0hIjFUhM0CAteEnVkiVG0Kg4LimR7Asq8PfjclEvrbCPsDntzy7rGKhTZObI9TZw=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.87.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA=="], + "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.99.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.99.2", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9RFOEdY+ZTJ1AI+UuAFs4RM0tF2Tje/R/CEv9gWTJHt0iTu8XHZs64U/EIZxNSllFmec/4HfwsOxYj1qQek9bg=="], "@earendil-works/pi-agent-core/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], @@ -2476,7 +2478,7 @@ "@earendil-works/pi-ai/typebox": ["typebox@1.3.34", "", {}, "sha512-wbnzrXXDW8xEFHDZZs2jo1MkhaYlKAY4FRhpBc1+2LF1fZVBGCXGdLEhA/Z/NBbgzJMFfeM8m7elKPa/+KxaUQ=="], - "@earendil-works/pi-client/@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + "@earendil-works/pi-client/@earendil-works/chord": ["@earendil-works/chord@0.99.2", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-2dJWiNsOJm/ioBl1fhpJmw0hIjFUhM0CAteEnVkiVG0Kg4LimR7Asq8PfjclEvrbCPsDntzy7rGKhTZObI9TZw=="], "@earendil-works/pi-coding-agent/@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.85.1", "", { "dependencies": { "@earendil-works/chord": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", "@earendil-works/pi-telemetry": "^0.85.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA=="], @@ -2498,7 +2500,7 @@ "@earendil-works/pi-coding-agent/undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], - "@earendil-works/pi-protocol/@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + "@earendil-works/pi-protocol/@earendil-works/chord": ["@earendil-works/chord@0.99.2", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-2dJWiNsOJm/ioBl1fhpJmw0hIjFUhM0CAteEnVkiVG0Kg4LimR7Asq8PfjclEvrbCPsDntzy7rGKhTZObI9TZw=="], "@earendil-works/pi-protocol/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], @@ -2682,7 +2684,7 @@ "@earendil-works/pi-agent-core/@earendil-works/pi-ai/https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai/openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], + "@earendil-works/pi-agent-core/@earendil-works/pi-ai/openai": ["openai@7.19.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ=="], "@earendil-works/pi-client/@earendil-works/chord/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 99a5ecef..d7602dc2 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -115,7 +115,7 @@ it. The Pi contract suite (`apps/server/src/harness/pi.contract.test.ts`) covers this against the real Pi agent loop; run it before bumping `@ai-sdk/harness-pi` or `@earendil-works/pi-coding-agent`. -`HARNESS=atomic` runs Atomic 0.9.24-alpha.1 in the Chopin server process through its +`HARNESS=atomic` runs Atomic 0.9.25 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use RPC mode, or substitute Atomic for Pi's runtime. Each harness session owns one in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see diff --git a/package.json b/package.json index fff3906c..04b4633f 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@ai-sdk/harness-pi": "1.0.128", "@ai-sdk/mcp": "2.0.60", "@ai-sdk/sandbox-just-bash": "1.0.126", - "@bastani/atomic": "0.9.24-alpha.1", + "@bastani/atomic": "0.9.25", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", From 83db22a7d853bc57c431d3b2d558f02b2205778c Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 20:40:54 -0700 Subject: [PATCH 07/17] Run the Planner as a full Atomic session in local mode Opt in with ATOMIC_PLANNER=full only under local authentication and the Atomic harness. Verify each Planner checkout's origin before enabling Atomic coding tools, builtins, and operator resources. Unregistered worker sessions and Planner sessions without a verified checkout retain the isolated boundary. Bind Chopin's Decisions to Atomic HostInput rather than intercepting tools. Map questionnaires and dialogs, preserve raw answers, label workflow requests, and withdraw pending cards on abort or timeout after durable persistence. Document the operator-level shell and filesystem trust change. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: bun test passed: 1706 pass, 2 PostgreSQL skips, 0 fail across 193 files Assistant-verification: bun test apps/server/src/harness passed: 141 tests, including full Atomic SDK tools, resources, cwd, HostInput and isolated fallback Assistant-verification: bun run types passed: all workspaces and E2E TypeScript checks Assistant-verification: bun run fix passed: formatting inspected; no errors and one existing lint warning Assistant-verification: bun run ci passed: formatting, lint, tokens, typography and design checks; no new design findings Assistant-verification: isolation mutation checks passed: builtins on caused 16 failures, skill discovery caused 2, and plain-turn result tool caused 9; all mutations reverted byte-for-byte Assistant-verification: relative documentation link check passed: 38 links and anchors resolved with exact casing Assistant-verification: qlty smells passed: no duplication findings; complexity diagnostics match existing adapter closure patterns and are recorded in implementation notes User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- AGENTS.md | 6 +- apps/server/src/agent/planner.ts | 12 +- apps/server/src/chat/service.ts | 13 +- apps/server/src/config.test.ts | 56 +++ apps/server/src/config.ts | 33 +- .../src/harness/atomic.contract.test.ts | 9 +- apps/server/src/harness/atomic/adapter.ts | 85 ++-- .../src/harness/atomic/checkout.test.ts | 40 ++ apps/server/src/harness/atomic/checkout.ts | 37 ++ apps/server/src/harness/atomic/full.test.ts | 188 +++++++++ apps/server/src/harness/atomic/full.ts | 17 + .../src/harness/atomic/human-input.test.ts | 369 ++++++++++++++++++ apps/server/src/harness/atomic/human-input.ts | 121 ++++++ apps/server/src/harness/harnesses.ts | 11 +- apps/server/src/harness/pi/model-stub.ts | 4 + apps/server/src/harness/session.test.ts | 53 +++ apps/server/src/harness/session.ts | 30 +- apps/server/src/questions/service.ts | 106 ++--- docs/authentication.md | 7 + docs/hosted-agent.md | 76 +++- docs/self-hosting.md | 96 +++-- packages/dialect/src/nodes/questionnaire.ts | 27 +- packages/dialect/src/validate.ts | 5 +- packages/protocol/question.d.ts | 2 + packages/question/src/answer.ts | 5 +- packages/question/src/draft.ts | 2 +- packages/question/src/question.test.ts | 13 + .../question/src/react/question-view.test.ts | 15 + packages/question/src/schema.ts | 34 +- 29 files changed, 1322 insertions(+), 150 deletions(-) create mode 100644 apps/server/src/harness/atomic/checkout.test.ts create mode 100644 apps/server/src/harness/atomic/checkout.ts create mode 100644 apps/server/src/harness/atomic/full.test.ts create mode 100644 apps/server/src/harness/atomic/full.ts create mode 100644 apps/server/src/harness/atomic/human-input.test.ts create mode 100644 apps/server/src/harness/atomic/human-input.ts diff --git a/AGENTS.md b/AGENTS.md index 00a1f940..def661c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,8 +113,10 @@ external implementation runs are durable. model-backed research request supplies its GitHub App token and Copilot entitlement. The database stores only a token-free owner reference and durable context. -- **Planner tools are repository-fixed.** The hosted runtime has no shell, - checkout, host filesystem, skills, plugins, or arbitrary GitHub access. +- **Planner tools are repository-fixed by default.** The hosted runtime has no shell, + checkout, host filesystem, skills, plugins, or arbitrary GitHub access. Local-only + `ATOMIC_PLANNER=full` with a verified checkout deliberately enables the operator's + Atomic tools and resources, including shell and filesystem access. - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. - **Persistence should precede publication.** Do not acknowledge or broadcast a diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index b0702b44..db9ba6f9 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -230,8 +230,16 @@ Questionnaires are created by \`ask\`, never by hand, and their answers are owne elsewhere — leave them alone when you rewrite around them. To take one out of the plan, use the \`detach_question\` operation rather than deleting the block.`; -export function plannerInstructions(repository: string, bootstrap?: string): string { - let access = `Read before you propose. The selected repository is ${repository}. Use +export function plannerInstructions( + repository: string, + bootstrap?: string, + checkout?: string, +): string { + let access = checkout + ? `The selected repository is ${repository}. Your working directory is the verified local checkout ${checkout}. +You have the operator's Atomic coding tools, workflows and resources, with shell and filesystem access as the local user. +Atomic human input, including workflow questions, goes to Chopin Decisions. Chopin's document tools remain fixed to this document.` + : `Read before you propose. The selected repository is ${repository}. Use \`read_repository_file\`, \`list_repository_tree\`, \`search_repository\` and \`repository_history\` for its code, and \`list_pull_requests\` and \`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository. diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 8a9cc96b..50f9c4d0 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -406,6 +406,7 @@ export type Room = { activeOwner?: () => Promise; persist: () => Promise; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; + checkout?: string; ownerAvailable?: () => Promise; jobs?: JobService; references?: ReferenceService; @@ -893,11 +894,15 @@ async function repositorySession( let result = await open(binding, { room: documentRoom(context), repository, - instructions: plannerInstructions( - `${repository.owner}/${repository.name}`, - bootstrap, - ), + instructions: checkout => + plannerInstructions( + `${repository.owner}/${repository.name}`, + bootstrap, + checkout, + ), model: context.config.model, + atomicPlanner: context.config.atomicPlanner, + checkout: context.checkout, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); opened = result.value; diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 396c1634..65aa8dd7 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { delimiter } from "node:path"; import { describe as description, load } from "./config"; @@ -21,6 +22,9 @@ function configured(overrides: Record = {}) { HARNESS: undefined, HARNESS_AUTH: undefined, AUTH_MODE: undefined, + ATOMIC_PLANNER: undefined, + ATOMIC_PLANNER_CHECKOUTS: undefined, + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: undefined, SERVER_HOST: undefined, PORT: undefined, MODEL: undefined, @@ -126,6 +130,58 @@ describe("configuration", () => { }); }); + it("allows full Atomic Planner only in local atomic mode, with either auth mode", () => { + let full = { + ATOMIC_PLANNER: "full", + AUTH_MODE: "local", + HARNESS: "atomic", + MODEL: "stub/model", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", + }; + for (let auth of ["auto", "ai-gateway"]) { + expect(configured({ ...full, HARNESS_AUTH: auth }).atomicPlanner).toEqual({ + checkouts: [], + }); + } + expect(configured().atomicPlanner).toBeUndefined(); + for (let mode of ["isolated", "FULL", " "]) { + expect(() => configured({ ...full, ATOMIC_PLANNER: mode })).toThrow("ATOMIC_PLANNER"); + } + for (let auth of [undefined, "hosted"]) { + expect(() => configured({ ...full, AUTH_MODE: auth })).toThrow("AUTH_MODE=local"); + } + for (let harness of [undefined, "pi", "copilot-sdk"]) { + expect(() => configured({ ...full, HARNESS: harness })).toThrow("HARNESS=atomic"); + } + }); + + it("validates checkout paths and an optional per-request input timeout", () => { + let full = { + ATOMIC_PLANNER: "full", + AUTH_MODE: "local", + HARNESS: "atomic", + MODEL: "stub/model", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", + }; + expect( + configured({ + ...full, + ATOMIC_PLANNER_CHECKOUTS: ["/tmp/one", "/tmp/two", "/tmp/one"].join(delimiter), + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "1234", + }).atomicPlanner, + ).toEqual({ checkouts: ["/tmp/one", "/tmp/two", "/tmp/one"], inputTimeoutMs: 1234 }); + for (let paths of ["relative", ["/tmp/one", "relative"].join(delimiter)]) { + expect(() => configured({ ...full, ATOMIC_PLANNER_CHECKOUTS: paths })).toThrow("absolute"); + } + for (let timeout of ["0", "-1", "1.5", "oops", "10ms", "2147483648"]) { + expect(() => configured({ ...full, ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout })).toThrow( + "ATOMIC_PLANNER_INPUT_TIMEOUT_MS", + ); + } + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d4b3da51..822bbc67 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,7 @@ * than a puzzling behaviour three screens later. */ +import { delimiter, isAbsolute } from "node:path"; import { loadAuth } from "./auth/config"; import type { AuthConfig } from "./auth/config"; @@ -18,6 +19,7 @@ export type Config = { model: string; harness: string; harnessAuth: string | undefined; + atomicPlanner?: { checkouts: string[]; inputTimeoutMs?: number }; /** * Whether to run the agent at all. * @@ -59,11 +61,39 @@ function model(harness: string): string { } return DEFAULT_MODEL; } -export function harnessSelection(): Pick { +export function harnessSelection(): Pick< + Config, + "host" | "harness" | "harnessAuth" | "atomicPlanner" +> { + let full = process.env.ATOMIC_PLANNER; + if (full && full !== "full") throw new Error("ATOMIC_PLANNER must be full or unset"); + if (full && (process.env.AUTH_MODE !== "local" || process.env.HARNESS !== "atomic")) { + throw new Error("ATOMIC_PLANNER=full requires AUTH_MODE=local and HARNESS=atomic"); + } + let checkouts = process.env.ATOMIC_PLANNER_CHECKOUTS?.split(delimiter) ?? []; + if (checkouts.some(path => !isAbsolute(path))) { + throw new Error( + "ATOMIC_PLANNER_CHECKOUTS must contain absolute paths separated by the platform path delimiter", + ); + } + let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS; + let inputTimeoutMs = rawTimeout === undefined ? undefined : Number(rawTimeout); + if ( + inputTimeoutMs !== undefined + && (!Number.isSafeInteger(inputTimeoutMs) || inputTimeoutMs < 1 + || inputTimeoutMs > 2_147_483_647) + ) { + throw new Error("ATOMIC_PLANNER_INPUT_TIMEOUT_MS must be an integer between 1 and 2147483647"); + } return { host: process.env.SERVER_HOST || "127.0.0.1", harness: process.env.HARNESS || "copilot-sdk", harnessAuth: process.env.HARNESS_AUTH || undefined, + ...(full + ? { + atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, + } + : {}), }; } @@ -133,6 +163,7 @@ export function describe(config: Config): string { config.devClient ? `client: vite (${config.devClient})` : "client: built", config.agent ? `agent: ${config.model} (on demand)` : "agent: off", `harness: ${config.harness}`, + ...(config.atomicPlanner ? ["Atomic Planner: full (local shell and filesystem access)"] : []), config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", admission, diff --git a/apps/server/src/harness/atomic.contract.test.ts b/apps/server/src/harness/atomic.contract.test.ts index 9a87841c..ea7514ed 100644 --- a/apps/server/src/harness/atomic.contract.test.ts +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -405,7 +405,14 @@ describe("atomic host isolation and model resolution", () => { let project = join(root, "project"); let previous = { home: process.env.HOME, cwd: process.cwd() }; try { - for (let base of [join(home, ".atomic", "agent"), join(home, ".pi", "agent"), project]) { + for ( + let base of [ + join(home, ".atomic", "agent"), + join(home, ".pi", "agent"), + join(home, ".agents"), + project, + ] + ) { for (let name of ["extensions", "skills/marker", "prompts"]) { await mkdir(join(base, name), { recursive: true }); } diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index f74712f5..9ea63be7 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -1,13 +1,9 @@ /** * Chopin's Atomic `HarnessV1` adapter. * - * Embeds Atomic in-process through its headless SDK. Each harness session owns - * one Atomic `AgentSession` with every shipped builtin package off, no Atomic - * coding tools, an inert resource loader, and in-memory session, settings, and - * credential stores. The model is offered only the host tools `HarnessAgent` - * passes for the turn, plus a terminating result tool on structured turns. The - * tool set is checked before every prompt and before every model request, and a - * mismatch fails the turn. + * Sessions are isolated by default: only Chopin host tools, no host resources, + * and a checked per-turn tool boundary. A registered local full Planner uses + * its verified checkout and Atomic resources instead, with Chopin as HostInput. */ import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; @@ -17,13 +13,15 @@ import { DefaultResourceLoader, FileAuthStorageBackend, getAgentConfigPaths, + getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@bastani/atomic"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fullPlanner } from "./full"; import type { HarnessV1, @@ -92,6 +90,7 @@ export type AtomicSettings = { model?: string; /** Providers registered by code rather than discovered from the host. */ providers?: Record; + fullPlanner?: boolean; }; export class ToolBoundaryError extends Error { @@ -107,6 +106,7 @@ export type TurnPolicy = { structured: boolean; systemPrompt: string; result?: { value: unknown }; + full?: boolean; }; /** @@ -132,11 +132,16 @@ export function atomicTurnExtension(policy: TurnPolicy): ExtensionFactory { }; }, }); - atomic.on("before_agent_start", () => { - atomic.setActiveTools(activeNames(policy)); - return { systemPrompt: policy.systemPrompt }; + atomic.on("before_agent_start", event => { + if (!policy.full) atomic.setActiveTools(activeNames(policy)); + return { + systemPrompt: policy.full + ? `${event.systemPrompt}\n\n${policy.systemPrompt}` + : policy.systemPrompt, + }; }); atomic.on("tool_call", event => { + if (policy.full && event.toolName !== ATOMIC_RESULT_TOOL_NAME) return; if (activeNames(policy).includes(event.toolName)) return; return { block: true, reason: `${event.toolName} is not available in this turn.` }; }); @@ -314,9 +319,11 @@ export function createAtomicAdapter( throw unsupported("resuming an in-memory session"); } let directory: string | undefined; + let full = settings.fullPlanner ? fullPlanner(startOptions.sessionId) : undefined; let sessionManager: SessionManager | undefined; let settingsManager = SettingsManager.inMemory(SETTINGS); let policy: TurnPolicy = { + full: !!full, hostNames: [], structured: false, systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, @@ -367,7 +374,7 @@ export function createAtomicAdapter( if (!run) return; if (event.type === "turn_start") { let offered = session.agent.state.tools.map(tool => tool.name); - if (!sameNames(offered, activeNames(policy))) { + if (!full && !sameNames(offered, activeNames(policy))) { run.failure ??= new ToolBoundaryError( `Atomic offered unexpected tools: ${offered.join(", ") || "none"}.`, ); @@ -427,37 +434,52 @@ export function createAtomicAdapter( policy.hostNames = hostNames; policy.structured = false; if (!live) { - directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); - sessionManager ??= SessionManager.inMemory(directory); + if (!full) directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); + let cwd = full?.cwd ?? directory!; + let agentDir = full ? getAgentDir() : directory!; + if (full) { + let file = join(agentDir, "settings.json"); + let saved = await readFile(file, "utf8").catch(error => { + if (error.code === "ENOENT") return "{}"; + throw error; + }); + settingsManager = SettingsManager.inMemory({ ...JSON.parse(saved), ...SETTINGS }); + } + sessionManager ??= SessionManager.inMemory(cwd); let loader = new DefaultResourceLoader({ - cwd: directory, - agentDir: directory, + cwd, + agentDir, settingsManager, - noExtensions: true, - noSkills: true, - noPromptTemplates: true, - noThemes: true, - noContextFiles: true, - systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, - appendSystemPrompt: [], + ...(full ? {} : { + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, + appendSystemPrompt: [], + }), extensionFactories: [atomicTurnExtension(policy)], }); await loader.reload(); let created = await createAgentSession({ - cwd: directory, - agentDir: directory, + cwd, + agentDir, modelRuntime: models, model, fallbackModels: [], sessionManager, settingsManager, resourceLoader: loader, - builtins: BUILTINS_OFF, - tools: [...hostNames, ATOMIC_RESULT_TOOL_NAME], + builtins: full ? undefined : BUILTINS_OFF, + tools: full ? undefined : [...hostNames, ATOMIC_RESULT_TOOL_NAME], + extensionBindings: full ? { humanInput: full.humanInput } : undefined, customTools: turn.tools.map(hostTool), }); let session = created.session; - let leak = hostLeak(session, created.extensionsResult.extensions.length, hostNames); + let leak = full + ? undefined + : hostLeak(session, created.extensionsResult.extensions.length, hostNames); if (leak || closed) { try { await session.dispose(); @@ -525,9 +547,10 @@ export function createAtomicAdapter( let text = promptText(turn.prompt); let agent = await prepare(turn); let structured = turn.responseFormat?.type === "json"; - let expected = structured - ? [...policy.hostNames, ATOMIC_RESULT_TOOL_NAME] + let names = full + ? agent.getActiveToolNames().filter(name => name !== ATOMIC_RESULT_TOOL_NAME) : policy.hostNames; + let expected = structured ? [...names, ATOMIC_RESULT_TOOL_NAME] : names; agent.setActiveToolsByName(expected); if (!sameNames(agent.getActiveToolNames(), expected)) { throw new ToolBoundaryError( @@ -561,7 +584,7 @@ export function createAtomicAdapter( try { if (!run.stopped) { run.emit({ type: "stream-start", modelId: agent.model?.id }); - await agent.prompt(text, { expandPromptTemplates: false }); + await agent.prompt(text, { expandPromptTemplates: !!full }); } if (run.failure) throw run.failure; if (run.stopped) return; diff --git a/apps/server/src/harness/atomic/checkout.test.ts b/apps/server/src/harness/atomic/checkout.test.ts new file mode 100644 index 00000000..8c90c900 --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.test.ts @@ -0,0 +1,40 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { verifiedCheckout } from "./checkout"; + +let root = await mkdtemp(join(tmpdir(), "chopin-checkouts-")); +afterAll(() => rm(root, { recursive: true, force: true })); +let repository = { owner: "Octo-Org", name: "Score" }; + +async function checkout(name: string, origin: string) { + let path = join(root, name); + await mkdir(path); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + let process = Bun.spawn(["git", "-C", path, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await process.exited).toBe(0); + } + return path; +} + +test("verifies origin owner/name for URL, SSH and alias checkouts, preserving candidate priority", async () => { + let paths = []; + for ( + let [index, origin] of [ + "https://github.com/octo-org/score.git", + "ssh://git@github.com/Octo-Org/Score", + "git@github.com:octo-org/score.git", + "workgit:octo-org/score.git", + ].entries() + ) { + let path = await checkout(String(index), origin); + paths.push(path); + expect(await verifiedCheckout(repository, [path])).toBe(await realpath(path)); + } + expect(await verifiedCheckout(repository, paths, paths[3])).toBe(paths[3]); + let mismatch = await checkout("mismatch", "https://github.com/other/score.git"); + expect(await verifiedCheckout(repository, [mismatch, paths[1]!], mismatch)).toBe(paths[1]); + expect(await verifiedCheckout(repository, [mismatch, root, join(root, "missing")])) + .toBeUndefined(); +}); diff --git a/apps/server/src/harness/atomic/checkout.ts b/apps/server/src/harness/atomic/checkout.ts new file mode 100644 index 00000000..f8589a63 --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.ts @@ -0,0 +1,37 @@ +import { realpath } from "node:fs/promises"; + +/** Origin host names may be SSH aliases; the repository coordinates must match. */ +export async function verifiedCheckout( + repository: { owner: string; name: string }, + checkouts: string[], + preferred?: string, +): Promise { + for (let candidate of preferred === undefined ? checkouts : [preferred, ...checkouts]) { + try { + let cwd = await realpath(candidate); + let git = async (...args: string[]) => { + let process = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + let output = await new Response(process.stdout).text(); + if (await process.exited !== 0) throw new Error("not a checkout"); + return output.trim(); + }; + if (await git("rev-parse", "--is-inside-work-tree") !== "true") continue; + let origin = await git("remote", "get-url", "origin"); + let path = origin.includes("://") + ? new URL(origin).pathname.replace(/^\//, "") + : /^[^/:]+:(.+)$/.exec(origin)?.[1]; + if ( + path?.replace(/\.git$/, "").toLowerCase() + === `${repository.owner}/${repository.name}`.toLowerCase() + ) { + return cwd; + } + } catch { + // A missing or unrelated checkout retains the isolated Planner boundary. + } + } + return undefined; +} diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts new file mode 100644 index 00000000..9330beee --- /dev/null +++ b/apps/server/src/harness/atomic/full.test.ts @@ -0,0 +1,188 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; +import { jsonSchema, tool } from "ai"; +import { + ATOMIC_DEFAULT_SYSTEM_PROMPT, + ATOMIC_RESULT_TOOL_NAME, + createAtomicAdapter, +} from "./adapter"; +import { registerFullPlanner } from "./full"; +import { startStubModelServer } from "../pi/model-stub"; +import type { HostInput, QuestionParams } from "@bastani/atomic"; + +let params: QuestionParams = { + questions: [{ + header: "Choice", + question: "Which option?", + options: [ + { label: "First", description: "One" }, + { label: "Second", description: "Two" }, + ], + }], +}; +let stub = startStubModelServer((prompt, prior) => + prior + ? { kind: "text", text: "Answered." } + : prompt === "question" + ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(params) } + : prompt === "cwd" + ? { kind: "tool", name: "bash", arguments: JSON.stringify({ command: "pwd" }) } + : { kind: "text", text: "Ready." } +); +afterAll(stub.stop); + +async function run(full: boolean, registered: boolean, prompt = "plain") { + let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); + let agentDir = join(root, "agent"); + let cwd = join(root, "checkout"); + let previous = process.env.ATOMIC_CODING_AGENT_DIR; + let received: QuestionParams[] = []; + let harness = createAtomicAdapter({ + auth: "ai-gateway", + fullPlanner: full, + model: "stub/model", + providers: { + stub: { + baseUrl: stub.baseUrl, + apiKey: "stub", + api: "openai-completions", + models: [{ + id: "model", + name: "Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + }], + }, + }, + }); + let sandbox = await createJustBashNetworkSandboxSession(); + let release: (() => void) | undefined; + try { + for ( + let path of [ + cwd, + join(agentDir, "skills", "marker"), + join(agentDir, "extensions"), + join(agentDir, "prompts"), + ] + ) await mkdir(path, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "OPERATOR-CONTEXT-MARKER"); + await writeFile( + join(agentDir, "skills", "marker", "SKILL.md"), + "---\nname: marker\ndescription: OPERATOR-SKILL-MARKER\n---\nTest skill.\n", + ); + await writeFile(join(agentDir, "prompts", "marker.md"), "OPERATOR-PROMPT-MARKER"); + await writeFile( + join(agentDir, "extensions", "marker.ts"), + `export default api => api.registerTool({name: "operator_tool", label: "Operator", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"marker"}], details:{}}; }});`, + ); + let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + process.env.ATOMIC_CODING_AGENT_DIR = agentDir; + let humanInput: HostInput = { + confirm: async () => false, + select: async () => undefined, + input: async () => undefined, + editor: async () => undefined, + questionnaire: async (value, options) => { + received.push(value); + expect(options.requestId).toBeString(); + return { + answers: [{ + questionIndex: 0, + question: value.questions[0]!.question, + kind: "option", + answer: "Second", + }], + cancelled: false, + }; + }, + }; + let sessionId = crypto.randomUUID(); + if (registered) release = registerFullPlanner(sessionId, { cwd, humanInput }); + let agent = new HarnessAgent({ + harness, + instructions: "CHOPIN-INSTRUCTIONS-MARKER", + permissionMode: "allow-reads", + tools: { + host_tool: tool({ + inputSchema: jsonSchema({ type: "object" }), + execute: async () => "host", + }), + }, + activeTools: ["host_tool"], + }); + let session = await agent.createSession({ sessionId, sandboxSession: sandbox }); + stub.requests.length = 0; + try { + let result = await agent.stream({ session, prompt }); + await result.consumeStream(); + await result.text; + return { cwd, received, requests: [...stub.requests] }; + } finally { + await session.destroy(); + } + } finally { + release?.(); + await harness.shutdown(); + await sandbox.destroy(); + if (previous === undefined) delete process.env.ATOMIC_CODING_AGENT_DIR; + else process.env.ATOMIC_CODING_AGENT_DIR = previous; + await rm(root, { recursive: true, force: true }); + } +} + +test("verified full Planner sessions load operator resources and expose Atomic tools beside host tools", async () => { + let result = await run(true, true); + let request = result.requests[0]!; + for ( + let name of [ + "read", + "bash", + "edit", + "write", + "ask_user_question", + "workflow", + "subagent", + "intercom", + "web_search", + "operator_tool", + "host_tool", + ] + ) expect(request.toolNames).toContain(name); + expect(request.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME); + for ( + let marker of [ + "OPERATOR-CONTEXT-MARKER", + "OPERATOR-SKILL-MARKER", + "CHOPIN-INSTRUCTIONS-MARKER", + result.cwd, + ] + ) expect(request.system).toContain(marker); + expect(request.system).not.toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); +}); + +test("full sessions use the checkout cwd and route ask_user_question through HostInput", async () => { + let cwd = await run(true, true, "cwd"); + expect(cwd.requests.at(-1)!.toolResults.join("\n")).toContain(cwd.cwd); + let question = await run(true, true, "question"); + expect(question.received).toEqual([params]); + expect(question.requests.at(-1)!.toolResults.join("\n")).toContain("Second"); + let prompt = await run(true, true, "/marker"); + expect(prompt.requests[0]!.prompt).toBe("OPERATOR-PROMPT-MARKER"); +}); + +test("workers without a verified registration and default-mode sessions stay isolated", async () => { + for (let [full, registered] of [[true, false], [false, true]]) { + let result = await run(full!, registered!); + expect(result.requests[0]!.toolNames).toEqual(["host_tool"]); + expect(result.requests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); + } +}); diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts new file mode 100644 index 00000000..458a84cb --- /dev/null +++ b/apps/server/src/harness/atomic/full.ts @@ -0,0 +1,17 @@ +import type { HostInput } from "@bastani/atomic"; + +export type FullPlanner = { cwd: string; humanInput: HostInput }; +let planners = new Map(); + +/** Only a verified Planner session is registered; background workers are not. */ +export function registerFullPlanner(sessionId: string, planner: FullPlanner): () => void { + if (planners.has(sessionId)) throw new Error("Atomic Planner session is already registered"); + planners.set(sessionId, planner); + return () => { + if (planners.get(sessionId) === planner) planners.delete(sessionId); + }; +} + +export function fullPlanner(sessionId: string): FullPlanner | undefined { + return planners.get(sessionId); +} diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts new file mode 100644 index 00000000..6903c397 --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -0,0 +1,369 @@ +import { afterEach, expect, test } from "bun:test"; +import * as Question from "@chopin/question"; +import * as Questions from "../../questions/service"; +import * as Store from "../../questions/store"; +import * as Plan from "../../plan/service"; +import * as Room from "../../plan/room"; +import { MemoryStorage } from "../../storage/memory/adapter"; +import * as Edit from "../../plan/edit"; +import { createHumanInput } from "./human-input"; +import type { Server } from "bun"; +import type { HostInputOptions, QuestionParams } from "@bastani/atomic"; +import type { Socket, SocketData } from "../../wire"; +import type { DocumentRoom } from "../../agent/tools"; + +let cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function fixture(timeoutMs?: number) { + let storage = new MemoryStorage(); + let now = new Date(); + await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_test", + repositoryOwner: "org", + repositoryName: "repo", + title: "Questions", + createdBy: "user", + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let frames: any[] = []; + let server = { + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Server; + let plan = await Plan.open(channel.id, { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }, server); + let room = { + id: channel.id, + plan, + server, + anchors() {}, + } as DocumentRoom; + let ws = { + data: { handle: "reader", room: channel.id, client: "client" }, + send(frame: string) { + frames.push(JSON.parse(frame)); + }, + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Socket; + cleanups.push(() => Plan.close(plan)); + let controller = new AbortController(); + let options: HostInputOptions = { + requestId: "request", + sessionId: "session", + signal: controller.signal, + }; + let input = createHumanInput(room, timeoutMs); + async function cards(count: number) { + await wait(() => Store.outstanding(plan.questions).length === count); + return Store.outstanding(plan.questions); + } + async function answer(id: string, value: number[] | string) { + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question closed"); + let definition = Store.get(plan.questions, id)!.definition; + let model = Question.restore(opened.model, definition); + let question = definition.questions[0]; + if (typeof value === "string") { + model.api.val([question.id, "mode"]).set("custom"); + if (value) model.api.str([question.id, "custom"]).ins(0, value); + } else if (question.multiple) { + for (let index of value) { + model.api.val([question.id, "options", question.options[index]!.id]).set(true); + } + } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + let patch = model.api.flush(); + if (patch) { + await Questions.edit(plan, ws, { + kind: "question:edit", + rid: "edit", + ts: 0, + id, + patch: [...patch.toBinary()], + }); + } + let current = Store.snapshot(plan.questions, id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(plan, server, channel.id, ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id, + revision: current.revision, + }); + } + return { input, plan, storage, frames, controller, options, cards, answer, ws, server, room }; +} + +async function wait(ready: () => boolean) { + for (let i = 0; i < 200; i++) { + if (ready()) return; + await Bun.sleep(5); + } + throw new Error("question did not arrive"); +} + +let params: QuestionParams = { + questions: [ + { + header: "Choice", + question: " Which? ", + options: [ + { label: " A ", description: " first ", preview: "preview A" }, + { label: "B", description: "second" }, + ], + }, + { + header: "Multiple", + question: "Select several", + multiSelect: true, + options: [ + { label: "C", description: "" }, + { label: "D", description: "" }, + ], + }, + { + header: "Custom", + question: "Or free text?", + options: [ + { label: "E", description: "" }, + { label: "F", description: "" }, + ], + }, + ], +}; + +test("HostInput appends one ordered questionnaire batch and returns typed answers verbatim", async () => { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Existing document\n\nLast paragraph.\n", + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + expect(cards.map(card => card.definition.questions[0].header)).toEqual([ + "Choice", + "Multiple", + "Custom", + ]); + expect(cards[0]!.definition.questions[0].options[0]).toMatchObject({ + label: " A ", + description: " first ", + }); + let source = Plan.source(f.plan); + expect(source.indexOf("Last paragraph.")).toBeLessThan(source.indexOf(" { + let f = await fixture(); + for (let [value, approved] of [[[0], true], [[1], false], ["Yes", false]] as const) { + let result = f.input.confirm("Proceed?", "This changes files.", f.options); + let [card] = await f.cards(1); + expect(card!.definition.questions[0].question).toBe("Proceed?\n\nThis changes files."); + await f.answer(card!.id, typeof value === "string" ? value : [...value]); + expect(await result).toBe(approved); + } + let selected = f.input.select("Pick", [" second ", "first", "first"], f.options); + let [card] = await f.cards(1); + expect(card!.definition.questions[0].options.map(option => option.label)).toEqual([ + " second ", + "first", + "first", + ]); + await f.answer(card!.id, [0]); + expect(await selected).toBe(" second "); + let custom = f.input.select("Pick", ["yes", "no"], f.options); + await f.answer((await f.cards(1))[0]!.id, "yes"); + expect(await custom).toBeUndefined(); +}); + +test("input and editor are editable free-text cards that round-trip through the document", async () => { + let f = await fixture(); + for (let method of ["input", "editor"] as const) { + let response = f.input[method]("Title", " initial or hint\n", f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]; + expect(question.options).toEqual([]); + expect(question.question).toBe("Title\n\n initial or hint\n"); + let source = Plan.source(f.plan); + let roundTrip = await Room.create(source); + expect(Room.project(roundTrip)).toContain( + `header="${method === "input" ? "Input" : "Editor"}"`, + ); + expect(Room.project(roundTrip)).not.toContain(" { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [1]); + for (let card of cards.slice(1)) { + await Questions.cancel(f.plan, f.server, f.room.id, f.ws, { + kind: "question:cancel", + ts: 0, + rid: "cancel", + id: card.id, + }); + } + expect(await response).toEqual({ + cancelled: true, + answers: [ + { questionIndex: 0, question: " Which? ", kind: "option", answer: "B" }, + ], + }); +}); + +test("abort withdraws only its request's open cards, persists cancellation, and ignores late submission", async () => { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [1]); + f.controller.abort(); + expect(await response).toEqual({ answers: [], cancelled: true }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.get(cards[0]!.id)?.status).toBe("answered"); + for (let card of cards.slice(1)) { + expect(f.plan.records.get(card.id)).toMatchObject({ status: "cancelled", resolver: "chopin" }); + expect(Plan.source(f.plan)).not.toContain(card.id); + expect(f.frames).toContainEqual( + expect.objectContaining({ + kind: "question:resolved", + id: card.id, + status: "cancelled", + resolver: "chopin", + }), + ); + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + ts: 0, + rid: "late", + id: card.id, + revision: 0, + }); + expect(f.plan.records.get(card.id)?.status).toBe("cancelled"); + } + let saved = await Plan.readStored((await f.storage.collaboration.load(f.room.id, new Date()))!); + expect(saved.source).not.toContain(cards[1]!.id); +}); + +test("timeout withdraws unanswered dialogs and returns no answer for every HostInput method", async () => { + let f = await fixture(30); + for ( + let invoke of [ + () => f.input.questionnaire(params, f.options), + () => f.input.confirm("Confirm", "Proceed?", f.options), + () => f.input.select("Select", ["A", "B"], f.options), + () => f.input.input("Input", undefined, f.options), + () => f.input.editor("Editor", undefined, f.options), + ] + ) { + let result = await invoke(); + expect( + result === undefined || result === false + || (typeof result === "object" && result.cancelled && result.answers.length === 0), + ).toBe(true); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + } + expect([...f.plan.records.values()].every(record => record.status === "cancelled")).toBe(true); +}); + +test("workflow identity labels each card while returned question text remains verbatim", async () => { + let f = await fixture(); + let response = f.input.questionnaire(params, { + ...f.options, + workflowRunId: "run-123", + workflowStageId: "review", + }); + let cards = await f.cards(3); + for (let card of cards) { + expect(card.definition.questions[0].question).toContain("Workflow run: run-123; stage: review"); + await f.answer(card.id, [0]); + } + expect((await response).answers.map(answer => answer.question)).toEqual( + params.questions.map(question => question.question), + ); +}); + +test("abort before presentation creates no card and cancellation waits for durable storage", async () => { + let f = await fixture(); + let cancelled = new AbortController(); + cancelled.abort(); + expect(await f.input.confirm("Confirm", "Proceed?", { ...f.options, signal: cancelled.signal })) + .toBe(false); + expect(f.plan.records.size).toBe(0); + let settled = false; + let result = f.input.confirm("Confirm", "Proceed?", f.options).then(value => { + settled = true; + return value; + }); + await f.cards(1); + let gate = Promise.withResolvers(); + let original = f.storage.collaboration.commit; + f.storage.collaboration.commit = async input => { + await gate.promise; + return original(input); + }; + f.controller.abort(); + await Bun.sleep(15); + expect(settled).toBe(false); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toHaveLength(0); + gate.resolve(); + expect(await result).toBe(false); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toHaveLength(1); +}); + +test("blank dialog titles and supplied empty choices remain valid verbatim inputs", async () => { + let f = await fixture(); + let result = f.input.select("", ["", " "], f.options); + let [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await result).toBe(""); + let source = Plan.source(f.plan); + let restored = await Room.create(source); + expect(Room.project(restored)).toContain('label=""'); + expect(Room.project(restored)).toContain('value=""'); + restored.doc.destroy(); +}); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts new file mode 100644 index 00000000..a3812a0c --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.ts @@ -0,0 +1,121 @@ +import * as Questions from "../../questions/service"; + +import type { + HostInput, + HostInputOptions, + QuestionAnswer, + QuestionnaireResult, + QuestionParams, +} from "@bastani/atomic"; +import type { DocumentRoom } from "../../agent/tools"; + +/** Atomic owns input requests; Chopin owns their shared decision records. */ +export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostInput { + async function questionnaire( + params: QuestionParams, + options: HostInputOptions, + ): Promise { + if (options.signal.aborted) return { answers: [], cancelled: true }; + let workflow = [ + options.workflowRunId === undefined ? undefined : `Workflow run: ${options.workflowRunId}`, + options.workflowStageId === undefined ? undefined : `stage: ${options.workflowStageId}`, + ].filter(value => value !== undefined).join("; "); + let definition = Questions.identify({ + questions: params.questions.map(question => ({ + header: question.header, + question: workflow ? `${question.question}\n\n${workflow}` : question.question, + options: question.options.map(option => ({ + label: option.label, + description: option.description, + })), + multiple: question.multiSelect ?? false, + })), + }, { verbatim: true }); + let deadline = new AbortController(); + let signal = AbortSignal.any([options.signal, deadline.signal]); + let timer = timeoutMs === undefined ? undefined : setTimeout(() => deadline.abort(), timeoutMs); + let ended; + try { + ended = await Questions.ask( + room.plan, + room.server, + room.id, + definition, + undefined, + room.anchors, + signal, + ); + } finally { + clearTimeout(timer); + } + if (signal.aborted) return { answers: [], cancelled: true }; + let answers: QuestionAnswer[] = []; + ended.forEach((outcome, questionIndex) => { + if (outcome.status !== "answered") return; + let answer = outcome.answers[0]!; + let question = params.questions[questionIndex]!; + let identity = { questionIndex, question: question.question }; + if (answer.custom !== undefined) { + answers.push({ ...identity, kind: "custom", answer: answer.custom }); + } else if (question.multiSelect) { + answers.push({ ...identity, kind: "multi", answer: null, selected: answer.choices ?? [] }); + } else { + let label = answer.choices![0]!; + let preview = question.options.find(option => option.label === label)?.preview; + answers.push({ + ...identity, + kind: "option", + answer: label, + ...(preview === undefined ? {} : { preview }), + }); + } + }); + return { answers, cancelled: ended.some(outcome => outcome.status === "cancelled") }; + } + async function single( + header: string, + question: string, + choices: string[], + options: HostInputOptions, + ) { + let result = await questionnaire({ + questions: [{ + header, + question, + options: choices.map(label => ({ label, description: "" })), + }], + }, options); + return result.cancelled ? undefined : result.answers[0]; + } + return { + questionnaire, + async confirm(title, message, options) { + let answer = await single("Confirm", `${title}\n\n${message}`, ["Yes", "No"], options); + return answer?.kind === "option" && answer.answer === "Yes"; + }, + async select(title, choices, options) { + let answer = await single("Select", title, choices, options); + return answer?.kind === "option" && answer.answer !== null && choices.includes(answer.answer) + ? answer.answer + : undefined; + }, + async input(title, placeholder, options) { + let answer = await single( + "Input", + [title, placeholder].filter(value => value !== undefined).join("\n\n"), + [], + options, + ); + return answer?.kind === "custom" ? answer.answer ?? undefined : undefined; + }, + async editor(title, initial, options) { + let answer = await single( + "Editor", + [title, initial].filter(value => value !== undefined).join("\n\n"), + [], + options, + ); + return answer?.kind === "custom" ? answer.answer ?? undefined : undefined; + }, + }; +} diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index 408d6f64..54ca0c19 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -4,6 +4,7 @@ import { createPiAdapter } from "./pi/adapter"; import type { HarnessV1 } from "@ai-sdk/harness"; import type { AtomicAuthMode } from "./atomic/adapter"; +import type { Config } from "../config"; const credentials = new Map string | undefined; @@ -17,10 +18,13 @@ function createPiHarness(settings: { auth?: string }): HarnessV1 & { shutdown(): } function createAtomicHarness( - settings: { auth?: string }, + settings: { auth?: string; fullPlanner?: boolean }, ): HarnessV1 & { shutdown(): Promise } { // Like Pi, Atomic takes neither a GitHub credential nor a credit limit. - return createAtomicAdapter({ auth: settings.auth as AtomicAuthMode }); + return createAtomicAdapter({ + auth: settings.auth as AtomicAuthMode, + fullPlanner: settings.fullPlanner, + }); } export const harnesses = { @@ -33,6 +37,7 @@ export const harnesses = { credentials: (id: string) => string | undefined; limits: (id: string) => { maxAiCredits: number } | undefined; auth?: string; + fullPlanner?: boolean; }) => HarnessV1 >; @@ -62,6 +67,7 @@ export function harnessFor(config: { harness: string; harnessAuth?: string; host?: string; + atomicPlanner?: Config["atomicPlanner"]; }): HarnessV1 { if (!Object.hasOwn(harnesses, config.harness)) { throw new Error(`Unknown harness: ${config.harness}`); @@ -93,6 +99,7 @@ export function harnessFor(config: { return maxAiCredits === undefined ? undefined : { maxAiCredits }; }, auth, + fullPlanner: !!config.atomicPlanner, }); } diff --git a/apps/server/src/harness/pi/model-stub.ts b/apps/server/src/harness/pi/model-stub.ts index 869992d9..21a25705 100644 --- a/apps/server/src/harness/pi/model-stub.ts +++ b/apps/server/src/harness/pi/model-stub.ts @@ -43,6 +43,7 @@ export type StubRequest = { system: string; toolNames: string[]; hasPriorToolResult: boolean; + toolResults: string[]; }; export function startStubModelServer( @@ -73,6 +74,9 @@ export function startStubModelServer( system, hasPriorToolResult, toolNames: (body.tools ?? []).map(entry => entry.function?.name ?? ""), + toolResults: body.messages.filter(message => message.role === "tool").map(message => + extractText(message.content) + ), }); let turn = respond(prompt, hasPriorToolResult); let id = crypto.randomUUID(); diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index d36e6709..35b6e353 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; import { openPlannerSession } from "./session"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fullPlanner } from "./atomic/full"; +import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { PlannerSessionDependencies } from "./session"; @@ -110,4 +115,52 @@ describe("openPlannerSession", () => { expect(result).toMatchObject({ ok: false, error: { kind: "Timeout" } }); expect(destroyed).toEqual({ sandbox: 1, session: 0, unregistered: 1 }); }); + it("registers full Planner input only for verified checkouts and releases it with the session", async () => { + let cwd = await mkdtemp(join(tmpdir(), "chopin-planner-session-")); + try { + for ( + let args of [["init", "--quiet"], ["remote", "add", "origin", "workgit:owner/repo.git"]] + ) { + expect( + await Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" }).exited, + ).toBe(0); + } + for (let checkouts of [[cwd], []]) { + let { owner, channel, deps } = fixture(); + let sessionId = ""; + let instructions = ""; + deps.agent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + }, + } as never; + let opened = await openPlannerSession(owner, { + ...channel, + atomicPlanner: { checkouts }, + instructions: checkout => plannerInstructions("owner/repo", "BOOTSTRAP", checkout), + }, deps); + expect(opened.ok).toBe(true); + if (!opened.ok) throw new Error("Planner unavailable"); + await opened.value.stream("prompt", new AbortController().signal); + expect(instructions).toContain("BOOTSTRAP"); + if (checkouts.length) { + expect(fullPlanner(sessionId)?.cwd).toBe(cwd); + expect(fullPlanner(sessionId)?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(cwd); + expect(instructions).not.toContain("You have no shell"); + } else { + expect(fullPlanner(sessionId)).toBeUndefined(); + expect(instructions).toContain("You have no shell"); + } + await opened.value.destroy(); + expect(fullPlanner(sessionId)).toBeUndefined(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index a22664a3..34f2823d 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -2,11 +2,15 @@ import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; import { plannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; +import { verifiedCheckout } from "./atomic/checkout"; +import { registerFullPlanner } from "./atomic/full"; +import { createHumanInput } from "./atomic/human-input"; import type { HarnessAgent } from "@ai-sdk/harness/agent"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { HostedRepository } from "../agent/repository"; import type { DocumentRoom } from "../agent/tools"; +import type { Config } from "../config"; export type OpenError = | GitHubToolsError @@ -18,11 +22,13 @@ type Sandbox = Awaited>; type PlannerAgent = typeof plannerAgent; -type PlannerChannel = { +export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string; + instructions: string | ((checkout?: string) => string); model?: string; + atomicPlanner?: Config["atomicPlanner"]; + checkout?: string; }; export type PlannerSession = { @@ -67,12 +73,29 @@ export async function openPlannerSession( return { ok: false, error: { kind: "Unavailable", cause } }; } let unregister: (() => void) | undefined; + let unregisterFull: (() => void) | undefined; let session: Awaited> | undefined; let timeout: ReturnType | undefined; try { if (owner.signal.aborted) throw new Error("Planner owner unavailable"); let sessionId = crypto.randomUUID(); unregister = (deps.registerCredential ?? registerCredential)(sessionId, owner.currentToken); + let cwd = channel.atomicPlanner + ? await verifiedCheckout( + channel.repository, + channel.atomicPlanner.checkouts, + channel.checkout, + ) + : undefined; + if (cwd) { + unregisterFull = registerFullPlanner(sessionId, { + cwd, + humanInput: createHumanInput(channel.room, channel.atomicPlanner?.inputTimeoutMs), + }); + } + let instructions = typeof channel.instructions === "function" + ? channel.instructions(cwd) + : channel.instructions; let agent = deps.agent ?? plannerAgent; let opening = agent.createSession({ sessionId, sandboxSession: sandbox }); let deadline = new Promise((_, reject) => { @@ -101,6 +124,7 @@ export async function openPlannerSession( abortSignal, options: { ...channel, + instructions, owner, githubTools: tools.value, }, @@ -114,6 +138,7 @@ export async function openPlannerSession( await sandbox.destroy(); } finally { release(); + unregisterFull?.(); } } })(), @@ -131,6 +156,7 @@ export async function openPlannerSession( console.error("[agent] Planner sandbox cleanup failed:", cleanupError); } unregister?.(); + unregisterFull?.(); let message = cause instanceof Error ? cause.message : String(cause); let kind: "Timeout" | "ShuttingDown" | "HarnessCapabilityUnsupported" | "Unavailable" = message.includes("timed out") diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index f007c2ce..296b2ed5 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -52,8 +52,8 @@ export type { StoredOpen } from "./store"; * a document the agent will rewrite around. Identity is minted once, here, * before anything references it. */ -export function identify(raw: unknown): Definition { - let definition = Question.normalize(raw); +export function identify(raw: unknown, options?: { verbatim?: boolean }): Definition { + let definition = Question.normalize(raw, options); return { questions: definition.questions.map(question => ({ ...question, @@ -103,7 +103,9 @@ export async function ask( definition: Definition, placement?: AskPlacement, created?: () => void, + signal?: AbortSignal, ): Promise { + if (signal?.aborted) return []; if (Service.implementationActive(plan)) throw new Error("implementation is active"); if (definition.questions.length === 0) { Question.reject("A questionnaire needs at least one question"); @@ -165,7 +167,23 @@ export async function ask( created?.(); }); - return Promise.all(asked.map(item => item.waiting)); + let failed = Promise.withResolvers(); + let withdrawing: Promise | undefined; + let abort = () => { + withdrawing ??= Promise.all( + asked.map(item => withdraw(plan, server, roomId, item.id, "chopin")), + ); + void withdrawing.catch(failed.reject); + }; + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + try { + let ended = await Promise.race([Promise.all(asked.map(item => item.waiting)), failed.promise]); + await withdrawing; + return ended; + } finally { + signal?.removeEventListener("abort", abort); + } } /** Validate every placement before registering records or document nodes. */ @@ -525,57 +543,51 @@ export async function cancel( msg: Request, ): Promise { if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); - let claimed = Store.claimCancel(plan.questions, msg.id, ws.data.handle); - if (!claimed.ok) { - return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...claimed }); - } - let mutationError: unknown; - let finish: (() => Store.Ended) | undefined; - await Service.exclusive(plan, async () => { + let result = await withdraw(plan, server, roomId, msg.id, ws.data.handle); + reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...result }); +} + +/** Withdraw input on behalf of a member or the Planner, after its durable commit. */ +export async function withdraw( + plan: Plan, + server: Server, + roomId: string, + id: string, + resolver: string, +): Promise { + return Service.exclusive(plan, async () => { + let claimed = Store.claimCancel(plan.questions, id, resolver); + if (!claimed.ok) return claimed; let mutation: room.Mutation | undefined; try { - mutation = room.removeQuestionnaire(plan.document, msg.id); + mutation = room.removeQuestionnaire(plan.document, id); } catch (err) { - mutationError = err; - return; + console.error("[questions] could not remove the node:", err); + Store.rollback(plan.questions, claimed.claim); + return { ok: false, reason: "resolving" }; } - let record = plan.records.get(msg.id); - if (record) { - plan.records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); + let record = plan.records.get(id); + if (record) plan.records.set(id, { ...record, status: "cancelled", resolver }); + let finish = Store.stage(plan.questions, claimed.claim); + try { + if (mutation) await Service.publish(plan, server, roomId, mutation); + else await Service.persistExclusive(plan); + } catch (err) { + plan.questions.closed.delete(id); + plan.questions.open.set(id, claimed.claim.entry); + Store.rollback(plan.questions, claimed.claim); + if (record) plan.records.set(id, record); + throw err; } - finish = Store.stage(plan.questions, claimed.claim); - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - }); - if (mutationError) { - // The questionnaire stays open and answerable, which is a state every - // client already renders. Saying "resolving" is honest: the attempt is - // over, and trying again is the right move. - console.error("[questions] could not remove the node:", mutationError); - Store.rollback(plan.questions, claimed.claim); - return reply(ws, msg.rid, { - kind: "question:cancel", + finish(); + broadcast(server, roomId, { + kind: "question:resolved", ts: 0, - id: msg.id, - ok: false, - reason: "resolving", + id, + status: "cancelled", + resolver, }); - } - finish!(); - - reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: true, - resolver: ws.data.handle, - }); - broadcast(server, roomId, { - kind: "question:resolved", - ts: 0, - id: msg.id, - status: "cancelled", - resolver: ws.data.handle, + return { ok: true, resolver }; }); } diff --git a/docs/authentication.md b/docs/authentication.md index ec24bed5..afd14dab 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -202,6 +202,13 @@ Each browser has its own binding; configured instance admission lists still apply to every authorized account. Loopback binding does not make this a mode for internet-facing or exposed multi-user deployments. +`ATOMIC_PLANNER=full` additionally enables local Atomic tools and resources for +Planner sessions with a verified repository checkout. It requires `HARNESS=atomic` +and grants shell and filesystem access as the operator; loopback is not a filesystem +sandbox. Both Atomic auth modes (`auto` and `ai-gateway`) are allowed. Keep this a +trusted single-operator instance, with no public proxy or tunnel. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode). + ## Instance admission `GITHUB_ALLOWED_USERS` and `GITHUB_ALLOWED_ORGANIZATIONS` are optional, diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index fae44c3c..9902716a 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -3,8 +3,8 @@ Chopin's Copilot-backed document agent is currently named Planner. It can inspect one selected GitHub repository, co-author the shared document, ask the participants structured questions, and anchor decisions to prose. For documents -used as plans, it can also draft an implementation graph. It does not implement -code or change GitHub. +used as plans, it can also draft an implementation graph. By default it does not +implement code or change GitHub; the local full Atomic mode below changes its tool boundary. The product role is document co-authoring. The current prompt and tool vocabulary remain optimized for planning and may structure another document type as a plan; @@ -15,8 +15,8 @@ from a code-owned map, defaulting to `copilot-sdk`, a host-process adapter over `@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, and `atomic`, which embeds Atomic's headless SDK (`@bastani/atomic`) in the server process, are further reviewed adapters. Both require an explicit `HARNESS_AUTH`. The -harness owns the agent loop and model; Chopin owns every tool the Planner can -call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` +harness owns the agent loop and model; by default Chopin owns every tool the Planner +can call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` selection and adapter trust. ## Ownership @@ -44,7 +44,7 @@ ownership generation. ## Runtime isolation -Every harness receives only Chopin's host-executed tools: document, question, +By default every harness receives only Chopin's host-executed tools: document, question, relationship, implementation-graph, repository, and GitHub tools, each bound to the channel's repository through `toolsContext`. No harness built-in is active for the Planner. The default `copilot-sdk` adapter additionally runs the shared @@ -53,7 +53,7 @@ owner's token when created and has no client-level service token or logged-in-user fallback. That detail is specific to the Copilot SDK adapter, not a property every harness in `HARNESS` shares. -The Planner has no: +The isolated Planner has no: - checkout, shell, or host filesystem; - skills, plugins, or configuration discovery; @@ -65,8 +65,8 @@ Under `HARNESS=pi`, Chopin patches `@ai-sdk/harness-pi` 1.0.128 so Pi does not load `AGENTS.md` or `CLAUDE.md` context files from the host filesystem. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness). -Under `HARNESS=atomic`, each harness session owns one in-process Atomic -`AgentSession`. It is built with every shipped Atomic package disabled +Under `HARNESS=atomic` without a verified full-mode checkout, each harness session +owns one in-process Atomic `AgentSession`. It is built with every shipped Atomic package disabled (workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding tools, and its resource loader discovers no extensions, skills, prompt templates, themes, or context files. Its working and configuration directory is @@ -97,15 +97,69 @@ REST tools construct owner and repository coordinates on the server, bound response sizes and line ranges, reject path escape, and post-filter code search by GitHub repository node ID. -The Planner does not see a user's local checkout, current branch, working tree, +The isolated Planner does not see a user's local checkout, current branch, working tree, or uncommitted changes. A coding agent must compare the repository context returned by Chopin with its checkout before claiming work. The server validates the shape of creation provenance but does not resolve its branch and commit against GitHub or independently inspect the coding agent's checkout. +## Full Atomic Planner in local mode + +`ATOMIC_PLANNER=full` is an explicit trust change, accepted only with +`AUTH_MODE=local` and `HARNESS=atomic`. Other combinations and unrecognized +nonempty values fail startup. Both `HARNESS_AUTH=auto` and `ai-gateway` work. +This gives the Planner shell and filesystem access **as the local operator**, +not a sandbox confined to a repository. Use it only on a trusted single-operator, +loopback-only instance; never expose that instance through a proxy or tunnel. + +Set `ATOMIC_PLANNER_CHECKOUTS` to absolute checkout paths separated by the +platform path delimiter (`:` on Unix, `;` on Windows). Before each Planner +session, Chopin checks candidate paths with Git and compares `origin`'s +owner/repository to the channel repository, case-insensitively. HTTPS, +`ssh://`, and scp-style remotes are accepted. Remote host spellings are ignored +because SSH aliases are common; this verifies repository coordinates, not the +authenticity of a remote host. A per-invocation candidate takes precedence over +the configured list. No verified checkout means the same isolated session as +before, with no Atomic resources or HostInput binding. + +With a verified checkout, it becomes the session's working directory. Atomic's +workflows, subagents, MCP, web access, Intercom, and default coding tools run +alongside Chopin's document tools. The normal Atomic agent directory (including +`ATOMIC_CODING_AGENT_DIR` or the legacy `PI_CODING_AGENT_DIR` override) supplies +extensions, skills, prompt templates, context files, and a read-only copy of its +settings. Chopin appends its Planner instructions to Atomic's assembled prompt. +Session, settings, and model credentials remain in memory; Atomic's own enabled +tools, extensions, MCP servers, and workflow storage can perform local writes. +Background summary and research workers still use isolated sessions. + +Chopin implements Atomic's `HostInput`, bound through +`extensionBindings.humanInput`; it does not intercept tools. `ask_user_question`, +extension dialogs, and workflow-stage input become ordinary shared Decisions: + +- Questionnaires retain question and option order, multi-selection, and custom + text; answers return their Atomic question indices and answer kinds, including + a selected option's preview. +- Confirm uses Yes/No; only choosing Yes approves. Select returns only a supplied + choice. Input and editor use free-text cards; hints and initial editor text + appear verbatim in the prompt. Explicitly submitted empty text is an answer, + not cancellation. +- Workflow cards show `Workflow run: ; stage: ` below their question. + A request is appended as one adjacent batch at the end of the document. +- An abort withdraws still-open cards, removes their document nodes, and records + cancellation by `@chopin` in Decisions. Member cancellations retain any answers + already given in that batch. Late submissions cannot approve withdrawn input. +- `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` optionally limits each request (a positive + integer in milliseconds, at most 2147483647). Unset waits indefinitely. Expiry + withdraws pending cards and returns no answer; confirm returns false. + +Chopin's question size limits still apply: oversized dialogs fail rather than +silently truncating text. Atomic keeps durable workflow approvals pending on +withdrawal; Chopin does not automatically resume workflows or replay interrupted +turns after a restart. A new Atomic session must explicitly resume a saved run. + ## Permission checks -Before each custom or MCP tool executes, callbacks recheck: +Before each Chopin host tool or its repository-bound GitHub MCP tool executes, callbacks recheck: - current instance admission; - the owner process session and its user; @@ -117,6 +171,8 @@ Before each custom or MCP tool executes, callbacks recheck: Permission is decided before execution. A refusal therefore produces no normal tool start or completion event; the Chat service renders permission denials explicitly so the boundary remains visible. +These checks do not mediate full mode's Atomic coding tools, operator extensions, +or operator-configured MCP servers; those use the operator's local authority. A harness session is bound to one credential revision. Before an eight-hour GitHub App token refresh, Chopin aborts and discards every Planner session diff --git a/docs/self-hosting.md b/docs/self-hosting.md index d7602dc2..af059da9 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -117,10 +117,9 @@ covers this against the real Pi agent loop; run it before bumping `HARNESS=atomic` runs Atomic 0.9.25 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use -RPC mode, or substitute Atomic for Pi's runtime. Each harness session owns one -in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see -[Hosted agent](hosted-agent.md#runtime-isolation) for the isolation it -enforces. +RPC mode, or substitute Atomic for Pi's runtime. By default each harness session owns +one in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see +[Hosted agent](hosted-agent.md#runtime-isolation) for isolation and its local full-mode exception. `HARNESS=atomic` requires an explicit `HARNESS_AUTH`, either `auto` or `ai-gateway`. Unset, `direct`, and every other value are refused at startup. @@ -165,14 +164,15 @@ Atomic caveats: enables its shipped packages, including a mandatory Intercom, and its coding tools. It discovers host resources, gives turns without instructions a coding-agent system prompt, and saves tool results over 50,000 characters to a - temporary file. The adapter overrides each default and fails closed on the + temporary file. The isolated adapter overrides each default and fails closed on the ones it can observe, but a new Atomic release can add a default the suite does - not check. + not check. Local full mode deliberately enables those capabilities. - Sessions live only in memory. `doStop` returns a state the adapter refuses to resume, so a session never survives a restart. Chopin does not resume harness sessions. -- Compaction, suspending a turn, and skills are unsupported. Atomic's automatic - retries remain on. +- Harness-level compaction, suspending a turn, and supplied harness skills are + unsupported. Full-mode resource discovery does load Atomic skills. Atomic's + automatic retries remain on. - Under `auto`, refreshing an OAuth token in memory can rotate the refresh token stored by the host CLI, which may then ask the operator to sign in again. `auto` also runs `!command` API-key entries in `auth.json` to resolve them. @@ -239,28 +239,31 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| ------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| Variable | Default | Meaning | +| --------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | +| `ATOMIC_PLANNER` | unset | Set exactly `full` to allow full Atomic Planner sessions. Requires `AUTH_MODE=local` and `HARNESS=atomic`; every other nonempty value or combination fails startup. Both Atomic auth modes are supported. | +| `ATOMIC_PLANNER_CHECKOUTS` | unset | Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. | +| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. @@ -417,6 +420,39 @@ App authorization, because device-issued refresh tokens can be refreshed without the client secret but GitHub's revocation endpoint requires it. Remove the App from **Settings > Applications** on GitHub to revoke it there. +### Opt into a full Atomic Planner + +Add these settings to the local-mode source deployment above, with an explicit +Atomic `MODEL` and either `HARNESS_AUTH=auto` or `HARNESS_AUTH=ai-gateway`: + +```bash +export HARNESS=atomic +export HARNESS_AUTH=auto +export MODEL="github-copilot/gpt-6-luna" +export ATOMIC_PLANNER=full +export ATOMIC_PLANNER_CHECKOUTS="/absolute/path/to/checkout" +# Optional: withdraw a request after ten minutes without a complete answer. +export ATOMIC_PLANNER_INPUT_TIMEOUT_MS=600000 +``` + +This is not the hosted tool boundary: verified Planner sessions enable Atomic's +shell, filesystem, builtins, and the operator's normal extensions, skills, +templates, and context files. The process runs with the operator's local +privileges. Local mode's loopback restriction and single-operator trust are +essential; never expose it publicly or forward it to untrusted users. + +Before a session starts, Chopin verifies that its cwd is a Git work tree whose +`origin` owner/repository matches the channel, ignoring case and remote host +spelling (so SSH host aliases work). A mismatching or unavailable checkout +falls back to the default isolated behavior, not to the server's cwd. +The check does not establish remote-host authenticity or confine shell access. + +Atomic input appears as shared Decisions instead of terminal dialogs, including +workflow run/stage labels. Unanchored batches append at the document's end. +Cancellation or timeout withdraws pending cards and never approves an action. +See [Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) +for mapping, resource loading, persistence, and workflow restart boundaries. + ## First-start smoke test Startup validates configuration, database connectivity, migration history, the diff --git a/packages/dialect/src/nodes/questionnaire.ts b/packages/dialect/src/nodes/questionnaire.ts index 814b6e6e..911f5797 100644 --- a/packages/dialect/src/nodes/questionnaire.ts +++ b/packages/dialect/src/nodes/questionnaire.ts @@ -15,7 +15,7 @@ import { $applyNodeReplacement, $getState, $setState, createState, DecoratorNode } from "lexical"; import { render } from "./render"; -import { attribute, attributes, identity, isFlow, PRIORITY } from "./shared"; +import { attribute, identity, isFlow, PRIORITY } from "./shared"; import type { LexicalExportVisitor, MdastImportVisitor } from "@mdxeditor/editor"; import type { @@ -210,16 +210,27 @@ export function toElement(value: Questionnaire): MdxJsxFlowElement { children: value.questions.map(question => ({ type: "mdxJsxFlowElement", name: "Question", - attributes: identity(question.id, { - header: question.header, - prompt: question.prompt, - multiple: String(question.multiple), - }), + attributes: [ + ...identity(question.id), + { type: "mdxJsxAttribute" as const, name: "header", value: question.header }, + { type: "mdxJsxAttribute" as const, name: "prompt", value: question.prompt }, + { type: "mdxJsxAttribute" as const, name: "multiple", value: String(question.multiple) }, + ], children: [ ...question.options.map(option => ({ type: "mdxJsxFlowElement" as const, name: "Option", - attributes: identity(option.id, { label: option.label, description: option.description }), + attributes: [ + ...identity(option.id), + { type: "mdxJsxAttribute" as const, name: "label", value: option.label }, + ...(option.description + ? [{ + type: "mdxJsxAttribute" as const, + name: "description", + value: option.description, + }] + : []), + ], children: [], })), // A projection of sidecar state: addressed through its Question, @@ -227,7 +238,7 @@ export function toElement(value: Questionnaire): MdxJsxFlowElement { ...(question.answer === undefined ? [] : [{ type: "mdxJsxFlowElement" as const, name: "Answer", - attributes: attributes({ value: question.answer }), + attributes: [{ type: "mdxJsxAttribute" as const, name: "value", value: question.answer }], children: [], }]), ], diff --git a/packages/dialect/src/validate.ts b/packages/dialect/src/validate.ts index 7f6857e4..1b0dca4a 100644 --- a/packages/dialect/src/validate.ts +++ b/packages/dialect/src/validate.ts @@ -249,7 +249,7 @@ class Validator { break; case "text": - if (!value.trim()) { + if (!value.trim() && !["Question", "Option", "Answer"].includes(spec.name)) { this.add("empty-attribute", `\`${spec.name}.${name}\` cannot be empty`, path, node); } else if (value.length > attribute.max) { this.add( @@ -302,7 +302,8 @@ class Validator { } found++; } - if (found === 0) { + // A free-text question has no options until its answer is projected. + if (found === 0 && spec.name !== "Question") { this.add( "missing-children", `\`${spec.name}\` requires at least one ${allowed.join(" or ")}`, diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index e22e5c9f..422cf634 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -48,6 +48,8 @@ export declare namespace Question { question: string; options: Option[]; multiple: boolean; + /** Host dialogs preserve raw text, including an explicitly submitted empty answer. */ + verbatim?: true; }; export type Definition = { diff --git a/packages/question/src/answer.ts b/packages/question/src/answer.ts index 799c64b5..6f85b9e0 100644 --- a/packages/question/src/answer.ts +++ b/packages/question/src/answer.ts @@ -39,7 +39,10 @@ export function derive(definition: Definition, drafts: Drafts): Outcome { } if (draft!.mode === "custom") { - answers.push({ question: question.question, custom: draft!.custom.trim() }); + answers.push({ + question: question.question, + custom: question.verbatim ? draft!.custom : draft!.custom.trim(), + }); continue; } diff --git a/packages/question/src/draft.ts b/packages/question/src/draft.ts index fa0036a2..0db10b8b 100644 --- a/packages/question/src/draft.ts +++ b/packages/question/src/draft.ts @@ -162,7 +162,7 @@ export function read(model: Model, definition: Definition): Drafts { /** Whether a question has enough of an answer to submit. */ export function answered(question: Item, draft: Draft | undefined): boolean { if (!draft) return false; - if (draft.mode === "custom") return !!draft.custom.trim(); + if (draft.mode === "custom") return question.verbatim || !!draft.custom.trim(); if (question.multiple) return question.options.some(option => draft.options[option.id]); return question.options.some(option => option.id === draft.choice); } diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index 9c6d9b97..792f5a9a 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -40,6 +40,19 @@ describe("normalize", () => { expect(definition.questions[0]!.header).toBe("Rollout"); expect(definition.questions[0]!.options[1]!.description).toBe(""); }); + it("supports verbatim host dialogs without choices, including empty text replies", () => { + let definition = normalize(tool({ header: " Title ", options: [] }), { verbatim: true }); + expect(definition.questions[0]!.header).toBe(" Title "); + let drafts = read(create(definition), definition); + expect(drafts.q0!.mode).toBe("custom"); + for (let custom of ["", " ", " answer\n"]) { + drafts.q0!.custom = custom; + expect(derive(definition, drafts)).toEqual({ + ok: true, + answers: [{ question: "How should we deploy?", custom }], + }); + } + }); it("rejects unknown fields instead of ignoring them", () => { expect(() => normalize({ questions: [], extra: 1 })).toThrow(QuestionError); diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index 0587907f..1252ac1e 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -3,6 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { currentQuestion, QuestionView } from "./question-view"; +import { create, normalize, read } from "../index"; test("a replacement definition falls back before rendering when its active question disappears", () => { let storage = { @@ -225,3 +226,17 @@ test("Next waits for an answer to the current question, but not in a read-only v "Next", )).not.toContain("disabled"); }); + +test("a free-text host dialog renders an editable textarea without inventing a choice", () => { + let definition = normalize({ + questions: [{ header: "Input", question: "Write the brief", options: [], multiple: false }], + }, { verbatim: true }); + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: read(create(definition), definition), + onChange() {}, + })); + expect(markup).toContain(", keys: string[], name: string): vo } } -function text(value: unknown, name: string, max: number, optional = false): string { +function text( + value: unknown, + name: string, + max: number, + optional = false, + verbatim = false, +): string { if (typeof value !== "string") fail(`${name} must be text`); - let result = value.trim(); + let result = verbatim ? value : value.trim(); if (!optional && !result) fail(`${name} is required`); if (result.length > max) fail(`${name} exceeds ${max} characters`); return result; @@ -62,7 +68,7 @@ function text(value: unknown, name: string, max: number, optional = false): stri * * @throws {QuestionError} */ -export function normalize(raw: unknown): Definition { +export function normalize(raw: unknown, { verbatim = false } = {}): Definition { let args = record(raw, "Question tool input"); exact(args, ["questions"], "Question tool input"); @@ -77,7 +83,7 @@ export function normalize(raw: unknown): Definition { let raw = record(source, `Question ${index + 1}`); exact(raw, ["header", "question", "options", "multiple"], `Question ${index + 1}`); - if (!Array.isArray(raw.options) || raw.options.length === 0) { + if (!Array.isArray(raw.options) || (!verbatim && raw.options.length === 0)) { fail(`Question ${index + 1} requires at least one option`); } if (raw.options.length > limits.MAX_OPTIONS) { @@ -96,12 +102,15 @@ export function normalize(raw: unknown): Definition { option.label, `Question ${index + 1} option ${position + 1} label`, limits.MAX_LABEL, + verbatim, + verbatim, ), description: text( option.description, `Question ${index + 1} option ${position + 1} description`, limits.MAX_DESCRIPTION, true, + verbatim, ), }; }); @@ -111,10 +120,23 @@ export function normalize(raw: unknown): Definition { return { id: `q${index}`, - header: text(raw.header, `Question ${index + 1} header`, limits.MAX_HEADER), - question: text(raw.question, `Question ${index + 1}`, limits.MAX_QUESTION), + header: text( + raw.header, + `Question ${index + 1} header`, + limits.MAX_HEADER, + verbatim, + verbatim, + ), + question: text( + raw.question, + `Question ${index + 1}`, + limits.MAX_QUESTION, + verbatim, + verbatim, + ), options, multiple: raw.multiple, + ...(verbatim ? { verbatim: true as const } : {}), }; }); From 4216cff1fbf80156ad3f7ea007a131a7fc32426f Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 20:45:08 -0700 Subject: [PATCH 08/17] Declare MCP output schemas as objects Wrap the rename, archive and restore result unions in object schemas without changing their branches. Check every exported tool and the fully enabled MCP tools/list response. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: focused MCP tests passed: 81 tests, 450 assertions; the new invariant failed before the fix on rename_document Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix and bun run ci passed: formatting inspected, no errors and one existing lint warning User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- apps/server/src/mcp.ts | 3 +++ apps/server/src/mcp/documents.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index e92f569a..b19d931d 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -402,6 +402,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome([ @@ -430,6 +431,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ARCHIVED_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), @@ -453,6 +455,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), diff --git a/apps/server/src/mcp/documents.test.ts b/apps/server/src/mcp/documents.test.ts index ba51ab99..8a522ee4 100644 --- a/apps/server/src/mcp/documents.test.ts +++ b/apps/server/src/mcp/documents.test.ts @@ -50,7 +50,37 @@ async function json(response: Response): Promise> { return await response.json() as Record; } +async function unavailable() { + return { kind: "unavailable" as const }; +} + describe("the MCP document protocol", () => { + it("exposes only top-level object output schemas for every tool", async () => { + let mcp = handler({ + caller: () => "octocat", + documents: reader(), + create: { create: unavailable }, + update: { update: unavailable }, + rename: { rename: unavailable }, + archive: { archive: unavailable }, + restore: { restore: unavailable }, + implementations: { + readImplementation: async () => undefined, + startImplementation: unavailable, + reportLifecycle: unavailable, + }, + }); + let response = await mcp(request({ jsonrpc: "2.0", id: 1, method: "tools/list" })); + let listed = (await response.json()).result.tools as typeof TOOLS; + expect(listed).toEqual(TOOLS); + for (let tool of [...TOOLS, ...listed]) { + expect({ name: tool.name, type: tool.outputSchema.type }).toEqual({ + name: tool.name, + type: "object", + }); + } + }); + it("authenticates initialize, lists repository documents, and reads canonical source", async () => { let mcp = endpoint(); From a79f75d7ccc311e510d27815b46e7f3a238bbe89 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 20:59:31 -0700 Subject: [PATCH 09/17] Let a local agent hand an instruction to the Planner Expose invoke_planner only for the full local Atomic mode. Resolve the existing document and require a live browser login for the MCP caller's GitHub identity; the bearer never supplies Planner ownership. Verify an explicit checkout before posting, and retain it only for its own turn. Persist the verbatim instruction as a member message before returning the canonical document URL. Keep a server-opened room alive through the turn and queue, without changing ordinary socket-only eviction. Document the local shell and filesystem trust boundary and refusal codes. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: bun test passed: 1718 pass, 2 PostgreSQL skips, 0 fail, 7344 assertions across 196 files Assistant-verification: focused MCP tests passed: 87 tests, including capability gating, input bounds, output schemas, session ownership and checkout verification Assistant-verification: harness tests passed: 141 tests; full Atomic and default isolated behavior remain covered Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix and bun run ci passed: formatting inspected; no errors, one existing lint warning and no new design findings Assistant-verification: relative documentation links passed: 26 targets and anchors resolved with exact casing Assistant-verification: qlty smells completed: guard-return complexity and existing archive/restore duplication recorded in implementation notes User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- apps/server/src/auth/session.test.ts | 20 +++ apps/server/src/auth/session.ts | 10 ++ apps/server/src/chat/address.ts | 3 +- apps/server/src/chat/invoke.test.ts | 241 ++++++++++++++++++++++++++ apps/server/src/chat/limits.ts | 1 + apps/server/src/chat/service.ts | 88 +++++++++- apps/server/src/main.ts | 58 +++++-- apps/server/src/mcp.ts | 97 +++++++++++ apps/server/src/mcp/documents.test.ts | 1 + apps/server/src/mcp/hosted.test.ts | 173 ++++++++++++++++++ apps/server/src/mcp/hosted.ts | 61 +++++++ apps/server/src/mcp/invoke.test.ts | 109 ++++++++++++ apps/server/src/rooms.test.ts | 35 ++++ apps/server/src/rooms.ts | 18 ++ docs/hosted-agent.md | 7 + docs/local-agent-mcp.md | 72 +++++++- docs/self-hosting.md | 7 + 17 files changed, 976 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/chat/invoke.test.ts create mode 100644 apps/server/src/chat/limits.ts create mode 100644 apps/server/src/mcp/invoke.test.ts create mode 100644 apps/server/src/rooms.test.ts diff --git a/apps/server/src/auth/session.test.ts b/apps/server/src/auth/session.test.ts index 395137f7..f7d6db91 100644 --- a/apps/server/src/auth/session.test.ts +++ b/apps/server/src/auth/session.test.ts @@ -30,6 +30,26 @@ function grant(accessToken: string, refreshToken = "ghr_refresh"): GitHubTokenGr } describe("hosted login sessions", () => { + it("finds the most recent live process-local session for only the requested user", async () => { + let storage = new MemoryStorage(); + let now = new Date("2026-08-13T12:00:00.000Z"); + for (let id of ["U_first", "U_second"]) { + await storage.users.put({ id, login: id, avatarUrl: "", now }); + } + let sessions = new Sessions(storage, false, () => now); + let first = await sessions.issue("U_first", grant("first-token")); + now = new Date(now.getTime() + 1_000); + let second = await sessions.issue("U_first", grant("newer-token")); + await sessions.issue("U_second", grant("other-user-token")); + expect((await sessions.forUser("U_first"))?.session.id).toBe(second.id); + expect(await sessions.forUser("U_unknown")).toBeUndefined(); + expect(await new Sessions(storage, false, () => now).forUser("U_first")).toBeUndefined(); + await sessions.revoke(request(pair(second.cookie))); + expect((await sessions.forUser("U_first"))?.session.id).toBe(first.id); + now = new Date(first.expiresAt); + expect(await sessions.forUser("U_first")).toBeUndefined(); + }); + it("stores only registry metadata while credentials remain process-local", async () => { let storage = new MemoryStorage(); let now = new Date("2026-08-13T12:00:00.000Z"); diff --git a/apps/server/src/auth/session.ts b/apps/server/src/auth/session.ts index 4fb66ec7..af801777 100644 --- a/apps/server/src/auth/session.ts +++ b/apps/server/src/auth/session.ts @@ -267,6 +267,16 @@ export class Sessions { : undefined; } + /** A local MCP handoff borrows an existing login, never the caller's bearer. */ + async forUser(userId: string): Promise { + for (let current of [...this.#sessions.values()].toReversed()) { + if (current.session.userId !== userId) continue; + let resolved = await this.resolve(current.session.id); + if (resolved) return resolved; + } + return undefined; + } + /** Read current credentials without triggering rotation from inside an active agent callback. */ async inspect(id: string): Promise { if (this.#refreshes.has(id) || this.#revocations.has(id)) return undefined; diff --git a/apps/server/src/chat/address.ts b/apps/server/src/chat/address.ts index 32371866..81dd2191 100644 --- a/apps/server/src/chat/address.ts +++ b/apps/server/src/chat/address.ts @@ -93,8 +93,9 @@ export function compose( ...backscroll.flatMap(said => said.references ?? []), ...references, ], + verbatim = false, ): string { - let asked = references.length > 0 ? text : instruction(text); + let asked = verbatim || references.length > 0 ? text : instruction(text); let readableIds = new Set(catalogReferences.map(reference => reference.id)); let current = annotatedText(asked, references, readableIds); diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts new file mode 100644 index 00000000..0e478a44 --- /dev/null +++ b/apps/server/src/chat/invoke.test.ts @@ -0,0 +1,241 @@ +import { expect, test } from "bun:test"; + +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { Admission } from "../auth/admission"; +import { Sessions } from "../auth/session"; +import * as Plan from "../plan/service"; +import { MemoryStorage } from "../storage/memory/adapter"; +import * as Chat from "./service"; + +import type { Server } from "bun"; +import type { HostedAuth } from "../auth/routes"; +import type { Config } from "../config"; +import type { GitHub } from "../github/client"; +import type { SocketData } from "../wire"; + +async function setup() { + let now = new Date(); + let storage = new MemoryStorage(); + let user = { id: "U_ana", login: "ana", avatarUrl: "" }; + await storage.users.put({ ...user, now }); + let sessions = new Sessions(storage, false); + let login = await sessions.issue(user.id, { + accessToken: "browser-token", + accessExpiresIn: 28_800, + refreshToken: "refresh-token", + refreshExpiresIn: 86_400, + }); + let repository = { id: "R_score", owner: "octo-org", name: "score", defaultBranch: "main" }; + let github = { + repositoryAccess: async () => ({ + ...repository, + permissions: { push: true, pull: true, admin: false }, + }), + } as unknown as GitHub; + let authConfig = { + origin: "http://localhost:8795", + appSlug: "test", + clientId: "test", + encryptionKey: new Uint8Array(32), + }; + let auth: HostedAuth = { + config: authConfig, + github, + storage, + sessions, + clock: () => new Date(), + admission: new Admission(authConfig, github), + }; + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: repository.id, + repositoryOwner: repository.owner, + repositoryName: repository.name, + title: "Document", + createdBy: user.id, + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let events: Array<{ kind: string; [key: string]: unknown }> = []; + let server = { + publish(_topic: string, message: string) { + events.push(JSON.parse(message)); + }, + } as unknown as Server; + let backend: Plan.Backend = { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }; + let plan = await Plan.open(channel.id, backend, server); + let owners = new ActiveOwnerBindings(auth); + let context: Chat.Room = { + chat: plan.chat, + plan, + room: channel.id, + server, + auth, + repository, + config: { agent: true } as Config, + claimantSessionId: login.id, + activeOwner: () => owners.resolve(channel.id), + persist: () => Plan.persist(plan), + }; + return { + context, + events, + user, + owners, + storage, + backend, + async close() { + owners.revokeAll(); + await Plan.close(plan); + }, + }; +} + +test("an invoked instruction persists verbatim as a member message before publication and returns before the turn", async () => { + let fixture = await setup(); + let { context, events, user } = fixture; + let saved = Promise.withResolvers(); + let persist = context.persist; + context.persist = async () => { + await saved.promise; + await persist(); + }; + let finished = Promise.withResolvers(); + let opened = Promise.withResolvers(); + let seen: Array<{ checkout?: string; prompt: string }> = []; + context.checkout = "/tmp/score"; + context.openPlannerSession = async (_owner, channel) => ({ + ok: true, + value: { + async stream(prompt) { + seen.push({ checkout: channel.checkout, prompt }); + opened.resolve(); + return { + fullStream: (async function*() { + await finished.promise; + yield { type: "finish" }; + })(), + } as never; + }, + destroy: async () => {}, + }, + }); + try { + let posting = Chat.invoke(context, user, " @chopin Plan this.\n"); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(events).toEqual([]); + expect(seen).toEqual([]); + saved.resolve(); + expect(await posting).toBeUndefined(); + await opened.promise; + expect(context.chat.busy).toBe(true); + expect(context.chat.entries[0]).toMatchObject({ + author: { kind: "member", handle: "ana" }, + text: " @chopin Plan this.\n", + }); + expect(events.some(event => event.kind === "chat:message")).toBe(true); + expect(seen[0]?.checkout).toBe("/tmp/score"); + expect(seen[0]?.prompt).toBe("@ana: @chopin Plan this.\n"); + finished.resolve(); + await context.chat.running; + expect(context.chat.busy).toBe(false); + } finally { + saved.resolve(); + finished.resolve(); + await fixture.close(); + } +}); + +test("queued invocations retain their own checkout and durable message exactly once", async () => { + let fixture = await setup(); + let { context, user } = fixture; + let first = Promise.withResolvers(); + let opened = Promise.withResolvers(); + let checkouts: Array = []; + context.openPlannerSession = async (_owner, channel) => ({ + ok: true, + value: { + async stream() { + checkouts.push(channel.checkout); + opened.resolve(); + return { + fullStream: (async function*() { + await first.promise; + yield { type: "finish" }; + })(), + } as never; + }, + destroy: async () => {}, + }, + }); + try { + await Chat.invoke({ ...context, checkout: "/tmp/first" }, user, "First"); + await opened.promise; + await Chat.invoke({ ...context, checkout: "/tmp/second" }, user, "Second"); + await Chat.invoke(context, user, "Second"); + expect(context.chat.waiting.map(item => item.text)).toEqual(["Second", "Second"]); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + expect(new Set(context.chat.entries.map(entry => entry.id)).size).toBe(3); + expect(checkouts).toEqual(["/tmp/first"]); + first.resolve(); + await context.chat.running; + expect(checkouts).toEqual(["/tmp/first", "/tmp/second", undefined]); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + expect(context.chat.waiting).toEqual([]); + expect(context.chat.busy).toBe(false); + await fixture.close(); + let restored = await Plan.open(context.room, fixture.backend, context.server); + expect(restored.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + await Plan.close(restored); + } finally { + first.resolve(); + await fixture.close(); + } +}); + +test("failed persistence, unavailable Planner, full queue and another owner start no invoked turn", async () => { + let fixture = await setup(); + let { context, user, events } = fixture; + try { + context.config.agent = false; + expect(await Chat.invoke(context, user, "Review")).toBe("planner-unavailable"); + context.config.agent = true; + context.chat.busy = true; + context.chat.waiting = Array.from( + { length: 20 }, + (_, index) => ({ id: String(index), handle: "ana", text: "Waiting" }), + ); + expect(await Chat.invoke(context, user, "Review")).toBe("planner-queue-full"); + context.chat.waiting = []; + context.persist = async () => { + throw new Error("storage failed"); + }; + await expect(Chat.invoke(context, user, "Review")).rejects.toThrow("storage failed"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + await fixture.storage.users.put({ id: "U_bob", login: "bob", avatarUrl: "", now: new Date() }); + let login = await context.auth.sessions.issue("U_bob", { + accessToken: "bob-token", + accessExpiresIn: 28_800, + refreshToken: "refresh", + refreshExpiresIn: 86_400, + }); + expect( + await Chat.invoke( + { ...context, claimantSessionId: login.id }, + { id: "U_bob", login: "bob" }, + "Review", + ), + ).toBe("planner-owner-unavailable"); + expect(context.chat.waiting).toEqual([]); + expect(context.chat.running).toBeUndefined(); + } finally { + await fixture.close(); + } +}); diff --git a/apps/server/src/chat/limits.ts b/apps/server/src/chat/limits.ts new file mode 100644 index 00000000..d8e42075 --- /dev/null +++ b/apps/server/src/chat/limits.ts @@ -0,0 +1 @@ +export const MAX_MESSAGE_BYTES = 64 * 1024; diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 50f9c4d0..9718953b 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -30,6 +30,7 @@ import { instruction } from "@chopin/protocol/address"; import { annotatedText, compose, referenceCatalog, remember } from "./address"; import { broadcast, fail, reply, tell } from "../wire"; +import { MAX_MESSAGE_BYTES } from "./limits"; import type { Server } from "bun"; import type { TextStreamPart, ToolSet } from "ai"; @@ -46,7 +47,6 @@ import type { Socket, SocketData } from "../wire"; /** Beyond this the queue is a backlog nobody is going to read. */ const MAX_QUEUE = 20; const MAX_PENDING_SENDS = 20; -const MAX_MESSAGE_BYTES = 64 * 1024; const MAX_SESSION_REFERENCES = 50; const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const FINGERPRINT = /^sha256:[0-9a-f]{64}$/; @@ -82,6 +82,9 @@ type Waiting = Wire.Waiting & { sessionId?: string; /** Verified member identity, retained only for a queued composer message. */ userId?: string; + posted?: boolean; + checkout?: string; + invokedBy?: string; }; export type ActiveMemberRequest = { @@ -407,6 +410,7 @@ export type Room = { persist: () => Promise; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; checkout?: string; + invokedBy?: string; ownerAvailable?: () => Promise; jobs?: JobService; references?: ReferenceService; @@ -440,6 +444,77 @@ export function send(context: Room, ws: Socket, msg: Request): Promis return completed; } +/** Accept a local MCP instruction without waiting for its Planner turn. */ +export async function invoke( + context: Room, + user: { id: string; login: string }, + text: string, +): Promise<"planner-unavailable" | "planner-owner-unavailable" | "planner-queue-full" | undefined> { + let { chat, server, room } = context; + if (chat.pendingSends >= MAX_PENDING_SENDS) return "planner-queue-full"; + chat.pendingSends++; + let post = async () => { + if (!context.config.agent || chat.closed) return "planner-unavailable" as const; + try { + let { owner } = await resolveOwner( + context.auth, + context.repository, + room, + context.claimantSessionId, + ); + if (owner.user.id !== user.id) return "planner-owner-unavailable" as const; + } catch { + return "planner-owner-unavailable" as const; + } + if (chat.closed) return "planner-unavailable" as const; + if (chat.busy && chat.waiting.length >= MAX_QUEUE) return "planner-queue-full" as const; + let entry: Wire.Entry = { + id: ulid(), + author: { kind: "member", handle: user.login }, + text, + ts: now(), + }; + chat.entries.push(entry); + try { + await context.persist(); + } catch (error) { + chat.entries = chat.entries.filter(value => value !== entry); + throw error; + } + if (chat.closed) return "planner-unavailable" as const; + announce(server, room, entry); + if (chat.busy) { + chat.waiting.push({ + id: entry.id, + handle: user.login, + text, + message: true, + posted: true, + sessionId: context.claimantSessionId, + userId: user.id, + checkout: context.checkout, + invokedBy: user.id, + }); + queued(chat, server, room); + } else { + startRun( + { ...context, invokedBy: user.id }, + user.login, + text, + undefined, + context.claimantSessionId, + false, + { entryId: entry.id, userId: user.id }, + ); + } + return undefined; + }; + let accepted = chat.sending.then(post, post); + let completed = accepted.finally(() => chat.pendingSends--); + chat.sending = completed.then(() => {}, () => {}); + return completed; +} + async function processSend(context: Room, ws: Socket, msg: Request): Promise { let { chat, room, server } = context; let suppliedRequestId = (msg as Request & { requestId?: unknown }).requestId; @@ -851,6 +926,9 @@ async function repositorySession( context.room, claimantSessionId, ); + if (context.invokedBy && owner.user.id !== context.invokedBy) { + throw new Error("The local Planner owner changed. Invoke the Planner again after signing in."); + } if (context.ownerAvailable) void context.ownerAvailable().catch(() => {}); let { chat, auth } = context; let lifecycle = chat.lifecycle; @@ -1032,7 +1110,7 @@ async function run( ]; retainReferences(chat, promptReferences); let available = promptReferences.filter(reference => chat.referenceCache.has(reference.id)); - let prompt = compose(backscroll, handle, text, references, available); + let prompt = compose(backscroll, handle, text, references, available, !!context.invokedBy); let result = await opened.session.stream( prompt, @@ -1095,9 +1173,9 @@ async function run( if (chat.closed) return; queued(chat, server, room); let entry = next.message ? chat.entries.find(item => item.id === next.id) : undefined; - if (entry) announce(server, room, entry); + if (entry && !next.posted) announce(server, room, entry); await run( - context, + { ...context, checkout: next.checkout, invokedBy: next.invokedBy }, next.handle, next.text, next.thread, @@ -1143,7 +1221,7 @@ function startRun( export function pending(chat: Chat): Waiting | undefined { let next = chat.waiting.shift(); while (next?.spent?.()) next = chat.waiting.shift(); - if (next?.message) { + if (next?.message && !next.posted) { let entry: MemberEntry = { id: next.id, author: { kind: "member", handle: next.handle }, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7212ab18..dd20ccba 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -161,8 +161,21 @@ async function plan(room: Rooms.Room, server: Server): Promise Service.persist(room.plan!), activeOwner: () => ownerBindings!.resolve(room.id), ownerAvailable: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), @@ -207,7 +215,7 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { async function closeRoom(room: Rooms.Room, force = false): Promise { if (room.closing) return room.closing; let closing = withDocumentLock(room.id, async () => { - if (!force && room.members.size > 0) return; + if (!force && (room.members.size > 0 || room.holds)) return; let held = room.plan; room.plan = undefined; if (held) await Service.close(held); @@ -222,10 +230,10 @@ async function closeRoom(room: Rooms.Room, force = false): Promise { } function evict(room: Rooms.Room): void { - if (room.eviction || room.members.size > 0) return; + if (room.eviction || room.members.size > 0 || room.holds) return; room.eviction = setTimeout(() => { room.eviction = undefined; - if (room.members.size > 0) return; + if (room.members.size > 0 || room.holds) return; void closeRoom(room).catch(err => { console.error("chopin: room close failed -", err); }); @@ -1110,6 +1118,34 @@ registerMcpRoutes(router, hostedAuth, { return heldLease; }, }, { + invokePlanner: config.atomicPlanner + ? input => + withDocumentTransition(input.channel.id, async () => { + let channel = await storage.channels.get(input.channel.id); + if (!channel || deletingChannels.has(channel.id)) return "document-unavailable"; + if (channel.archivedAt) return "document-archived"; + await Rooms.get(channel.id)?.closing; + let held = Rooms.hold(channel.id); + let release = () => { + held.release(); + evict(held.room); + }; + let running = false; + try { + await plan(held.room, server); + let context = conversation(held.room, input.session.session.id, input.repository); + context.checkout = input.checkout; + let code = await Chat.invoke(context, input.session.user, input.instruction); + if (!code && context.chat.running) { + running = true; + void context.chat.running.finally(release).catch(() => {}); + } + return code; + } finally { + if (!running) release(); + } + }) + : undefined, archiveChannel, isChannelDeleting: channelId => deletingChannels.has(channelId), onChannelRenamed: announceChannel, diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index b19d931d..e4484ab3 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -5,6 +5,9 @@ * module only validates and presents their results through MCP. */ +import { isAbsolute } from "node:path"; +import { MAX_MESSAGE_BYTES } from "./chat/limits"; + import { BRIEF, isRepository, @@ -141,6 +144,24 @@ export type RestoreDocument = { >; }; +export type InvokePlannerInput = { id: string; instruction: string; checkout?: string }; + +export type InvokePlannerError = + | "document-unavailable" + | "repository-forbidden" + | "document-archived" + | "planner-owner-unavailable" + | "checkout-unverified" + | "planner-unavailable" + | "planner-queue-full"; + +export type InvokePlanner = { + invoke(caller: Caller, input: InvokePlannerInput): Promise< + | { kind: "invoked"; document: DocumentSummary & { url: string } } + | { kind: "refused"; code: InvokePlannerError } + >; +}; + export type McpOptions = { /** The host owns authentication; MCP only receives its result. */ caller(request: Request): Promise | Caller | undefined; @@ -150,6 +171,7 @@ export type McpOptions = { rename?: RenameDocument; archive?: ArchiveDocument; restore?: RestoreDocument; + invoke?: InvokePlanner; implementations?: Implementations; }; @@ -462,6 +484,56 @@ export const TOOLS: Tool[] = [ ], }, }, + { + name: "invoke_planner", + description: "Post an instruction to a document's Planner as the locally signed-in user and " + + "return its URL without waiting for the turn. Available only in full Atomic mode; " + + "a supplied checkout must match the document repository. Questions appear in Decisions.", + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: MAX_DOCUMENT_LOCATOR_LENGTH, + pattern: "\\S", + }, + instruction: { + type: "string", + minLength: 1, + maxLength: MAX_MESSAGE_BYTES, + pattern: "\\S", + description: "Instruction text, at most 64 KiB in UTF-8.", + }, + checkout: { + type: "string", + description: "Absolute path to a local checkout of this repository.", + }, + }, + required: ["id", "instruction"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + properties: { ...DOCUMENT_IDENTITY, url: { type: "string" } }, + required: ["id", "title", "url"], + additionalProperties: false, + }, + outcome([ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ]), + ], + }, + }, { name: "read_implementation", description: @@ -663,6 +735,7 @@ function serviceInstructions(tools: Tool[]): string | undefined { || tool.name === "rename_document" || tool.name === "archive_document" || tool.name === "restore_document" + || tool.name === "invoke_planner" ); let implementation = tools .filter(tool => @@ -733,6 +806,7 @@ export function handler( && (tool.name !== "rename_document" || renaming) && (tool.name !== "archive_document" || archiving) && (tool.name !== "restore_document" || restoring) + && (tool.name !== "invoke_planner" || options.invoke) && (!["read_implementation", "start_implementation"].includes(tool.name) || options.implementations) && (!isLifecycleTool(tool.name) || options.implementations?.reportLifecycle) @@ -917,6 +991,29 @@ export function handler( : "document-unavailable", }, true)); } + if (tool.name === "invoke_planner" && options.invoke) { + let args = tool.arguments; + if ( + Object.keys(args).some(key => !["id", "instruction", "checkout"].includes(key)) + || !isLocator(args.id) + || typeof args.instruction !== "string" || !args.instruction.trim() + || Buffer.byteLength(args.instruction) > MAX_MESSAGE_BYTES + || (Object.hasOwn(args, "checkout") + && (typeof args.checkout !== "string" || !isAbsolute(args.checkout))) + ) { + return notification + ? undefined + : error( + call.id, + -32602, + "invoke_planner requires an id or URL, instruction, and optional absolute checkout", + ); + } + let result = await options.invoke.invoke(caller, args as InvokePlannerInput); + return result.kind === "invoked" + ? respond(text(result.document)) + : respond(text({ code: result.code }, true)); + } if (tool.name === "read_implementation") { if (Object.keys(tool.arguments).length !== 1 || !isLocator(tool.arguments.id)) { return notification diff --git a/apps/server/src/mcp/documents.test.ts b/apps/server/src/mcp/documents.test.ts index 8a522ee4..19ad96a4 100644 --- a/apps/server/src/mcp/documents.test.ts +++ b/apps/server/src/mcp/documents.test.ts @@ -64,6 +64,7 @@ describe("the MCP document protocol", () => { rename: { rename: unavailable }, archive: { archive: unavailable }, restore: { restore: unavailable }, + invoke: { invoke: async () => ({ kind: "refused", code: "planner-unavailable" }) }, implementations: { readImplementation: async () => undefined, startImplementation: unavailable, diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 6c771cca..97ec274c 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ulid } from "@chopin/dialect"; @@ -24,6 +27,7 @@ import type { } from "../github/client"; import type { CreateDocumentInput } from "../mcp"; import type { Socket, SocketData } from "../wire"; +import type { PlannerInvocation } from "./hosted"; let creation: CreateDocumentInput = { idempotencyKey: "create-plan-1", @@ -204,6 +208,175 @@ async function plan(context: ReturnType) { } describe("the hosted MCP adapter", () => { + it("invokes only for a live local login of the MCP caller and verifies a supplied checkout", async () => { + let context = setup(); + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + let started: PlannerInvocation[] = []; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return undefined; + }, + }); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let input = { id: opened.channel.id, instruction: " Review this.\n" }; + try { + expect(hosted(context.auth).invoke).toBeUndefined(); + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "planner-owner-unavailable", + }); + await context.storage.users.put({ + id: "U_other", + login: "other", + avatarUrl: "", + now: context.now, + }); + await context.auth.sessions.issue("U_other", grant("another-browser")); + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "planner-owner-unavailable", + }); + let session = await context.auth.sessions.issue(caller.user.id, grant("browser-token")); + expect(await adapter.invoke!.invoke(caller, { ...input, checkout: "/does-not-exist" })) + .toEqual({ kind: "refused", code: "checkout-unverified" }); + expect(started).toEqual([]); + let result = await adapter.invoke!.invoke(caller, input); + expect(result).toEqual({ + kind: "invoked", + document: { + id: opened.channel.id, + title: opened.channel.title, + url: "https://chopin.test/documents/octo-org/score/release-readiness", + }, + }); + expect(started).toHaveLength(1); + expect(started[0]).toMatchObject({ + instruction: input.instruction, + session: { session: { id: session.id }, user: { id: caller.user.id } }, + }); + expect(started[0]!.checkout).toBeUndefined(); + } finally { + await Service.close(opened.plan); + } + }); + + it("passes a matching checkout and local session through a URL invocation", async () => { + let context = setup(); + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + context.github.repositoryValue.permissions.admin = true; + let opened = await plan(context); + let checkout = await mkdtemp(join(tmpdir(), "chopin-mcp-checkout-")); + let started: PlannerInvocation[] = []; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return undefined; + }, + }); + try { + let caller = (await adapter.caller(request("Bearer allowed")))!; + let session = await context.auth.sessions.issue(caller.user.id, grant("local-browser-token")); + for ( + let args of [["init", "--quiet"], [ + "remote", + "add", + "origin", + "workgit:other/repository.git", + ]] + ) { + let git = Bun.spawn(["git", "-C", checkout, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + } + let input = { + id: "https://chopin.test/documents/octo-org/score/release-readiness", + instruction: "Review", + checkout, + }; + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "checkout-unverified", + }); + expect(started).toHaveLength(0); + let git = Bun.spawn([ + "git", + "-C", + checkout, + "remote", + "set-url", + "origin", + "workgit:octo-org/score.git", + ], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + expect(await adapter.invoke!.invoke(caller, input)).toMatchObject({ + kind: "invoked", + document: { id: opened.channel.id, url: input.id }, + }); + expect(started).toHaveLength(1); + expect(started[0]).toMatchObject({ + checkout: await realpath(checkout), + instruction: input.instruction, + session: { session: { id: session.id } }, + }); + } finally { + await Service.close(opened.plan); + await rm(checkout, { recursive: true, force: true }); + } + }); + + it("refuses unavailable, forbidden, archived and deleting documents before starting a Planner", async () => { + let context = setup(); + let opened = await plan(context); + let calls = 0; + let deleting = false; + let callbacks = { + async invokePlanner() { + calls++; + return undefined; + }, + isChannelDeleting: () => deleting, + }; + expect(hosted(context.auth, undefined, callbacks).invoke).toBeUndefined(); + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + let adapter = hosted(context.auth, undefined, callbacks); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let invoke = (id = opened.channel.id) => + adapter.invoke!.invoke(caller, { id, instruction: "Review" }); + try { + expect(await invoke(crypto.randomUUID())).toEqual({ + kind: "refused", + code: "document-unavailable", + }); + expect(await invoke()).toEqual({ kind: "refused", code: "repository-forbidden" }); + context.github.repositoryValue.permissions.push = true; + context.github.repositoryValue.permissions.pull = false; + expect(await invoke()).toEqual({ kind: "refused", code: "repository-forbidden" }); + context.github.repositoryValue.permissions.pull = true; + deleting = true; + expect(await invoke()).toEqual({ kind: "refused", code: "document-unavailable" }); + deleting = false; + await context.storage.channels.archive({ id: opened.channel.id, now: context.now }); + expect(await invoke()).toEqual({ kind: "refused", code: "document-archived" }); + expect(calls).toBe(0); + } finally { + await Service.close(opened.plan); + } + }); + it("accepts exactly one bearer token and validates its GitHub identity per request", async () => { let { auth, github } = setup(); let adapter = hosted(auth); diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index bf59bd2f..9661b247 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -8,14 +8,18 @@ import * as Rooms from "../rooms"; import { claimImplementation, reportImplementationLifecycle } from "../tasks/plan-graphs"; import { StorageError } from "../storage/errors"; import { implementationLifecycle } from "../tasks/lifecycle"; +import { verifiedCheckout } from "../harness/atomic/checkout"; import type { Server } from "bun"; import type { HostedAuth } from "../auth/routes"; import type { GitHubUser } from "../github/client"; +import type { AuthenticatedSession } from "../auth/session"; +import type { HostedRepository } from "../agent/repository"; import type { DocumentSummary, Implementation, ImplementationInput, + InvokePlannerError, McpOptions, RenameDocumentInput, } from "../mcp"; @@ -30,6 +34,14 @@ export type HostedCaller = { user: GitHubUser; }; +export type PlannerInvocation = { + channel: ChannelRecord; + repository: HostedRepository; + session: AuthenticatedSession; + instruction: string; + checkout?: string; +}; + export type ImplementationPersistence = { lease(): Lease }; export type HostedCallbacks = { archiveChannel?: (channelId: string, now: Date) => Promise; @@ -38,6 +50,7 @@ export type HostedCallbacks = { serializeDocument?: (channelId: string, action: () => Promise) => Promise; onChannelRenamed?: (channel: ChannelRecord) => void; onDocumentPersisted?: (target: Plan.DocumentTarget) => void; + invokePlanner?: (input: PlannerInvocation) => Promise; }; const BEARER = new RegExp("^Bearer ([A-Za-z0-9._~+/-]+=*)$", "i"); @@ -255,6 +268,54 @@ export function hosted( } return { + invoke: auth.config.local && callbacks.invokePlanner + ? { + async invoke(caller, input) { + let located = await locatedChannel(caller, input.id); + if (!located) return { kind: "refused", code: "document-unavailable" }; + if ( + located === "forbidden" + || (!located.repository.permissions.push && !located.repository.permissions.admin) + ) { + return { kind: "refused", code: "repository-forbidden" }; + } + let { channel, repository } = located; + if (callbacks.isChannelDeleting?.(channel.id)) { + return { + kind: "refused", + code: "document-unavailable", + }; + } + if (channel.archivedAt) return { kind: "refused", code: "document-archived" }; + let session = await auth.sessions.forUser(caller.user.id); + if (!session) return { kind: "refused", code: "planner-owner-unavailable" }; + let checkout = input.checkout === undefined + ? undefined + : await verifiedCheckout(repository, [], input.checkout); + if (input.checkout !== undefined && !checkout) { + return { kind: "refused", code: "checkout-unverified" }; + } + let code = await callbacks.invokePlanner!({ + channel, + repository, + session, + instruction: input.instruction, + checkout, + }); + if (code) return { kind: "refused", code }; + return { + kind: "invoked", + document: { + ...summary(channel), + url: new URL( + documentPath(channel.repositoryOwner, channel.repositoryName, channel.slug), + auth.config.origin, + ).href, + }, + }; + }, + } + : undefined, async caller(request) { let match = request.headers.get("authorization")?.match(BEARER); if (!match) return undefined; diff --git a/apps/server/src/mcp/invoke.test.ts b/apps/server/src/mcp/invoke.test.ts new file mode 100644 index 00000000..e774e92e --- /dev/null +++ b/apps/server/src/mcp/invoke.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; + +import { handler, TOOLS } from "../mcp"; + +import type { InvokePlanner, InvokePlannerInput } from "../mcp"; + +function endpoint(invoke?: InvokePlanner) { + return handler({ + caller: () => "octocat", + documents: { list: async () => [], read: async () => undefined }, + invoke, + }); +} + +async function call(mcp: ReturnType, method: string, params?: unknown) { + let response = await mcp( + new Request("http://localhost/mcp", { + method: "POST", + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }), + ); + return response.json(); +} + +test("invoke_planner is available only with its capability and returns the document URL", async () => { + let input = { + id: "/documents/octo-org/score/release", + instruction: " Plan this.\n", + checkout: "/tmp/score", + }; + let document = { + id: "document-id", + title: "Release", + url: "http://localhost/documents/octo-org/score/release", + }; + let received: Array<{ caller: string; input: InvokePlannerInput }> = []; + let mcp = endpoint({ + async invoke(caller, input) { + received.push({ caller, input }); + return { kind: "invoked", document }; + }, + }); + let tool = TOOLS.find(tool => tool.name === "invoke_planner"); + expect(tool).toBeDefined(); + expect((await call(endpoint(), "tools/list")).result.tools).not.toContainEqual(tool); + expect( + (await call(endpoint(), "tools/call", { name: "invoke_planner", arguments: input })).error.code, + ).toBe(-32601); + expect((await call(mcp, "tools/list")).result.tools).toContainEqual(tool); + expect((await call(mcp, "initialize", {})).result.instructions).toContain("invoke_planner:"); + let result = await call(mcp, "tools/call", { name: "invoke_planner", arguments: input }); + expect(result.result.structuredContent).toEqual(document); + expect(received).toEqual([{ caller: "octocat", input }]); +}); + +test("invoke_planner validates strict arguments and counts instruction bytes without rewriting text", async () => { + let received: InvokePlannerInput[] = []; + let mcp = endpoint({ + async invoke(_caller, input) { + received.push(input); + return { kind: "invoked", document: { id: input.id, title: "Document", url: "/document" } }; + }, + }); + let valid = { id: "document-id", instruction: "go" }; + for ( + let args of [ + {}, + { ...valid, id: " " }, + { ...valid, instruction: " \n\t" }, + { ...valid, instruction: "a".repeat(65_537) }, + { ...valid, instruction: "é".repeat(32_769) }, + { ...valid, instruction: 3 }, + { ...valid, checkout: "relative/path" }, + { ...valid, checkout: null }, + { ...valid, checkout: "" }, + { ...valid, extra: true }, + ] + ) { + let result = await call(mcp, "tools/call", { name: "invoke_planner", arguments: args }); + expect(result.error.code).toBe(-32602); + } + expect(received).toEqual([]); + let exact = { ...valid, instruction: "é".repeat(32_768) }; + expect( + (await call(mcp, "tools/call", { name: "invoke_planner", arguments: exact })).result.isError, + ).toBeUndefined(); + expect(received).toEqual([exact]); +}); + +test("invoke_planner exposes each refusal as an object code", async () => { + for ( + let code of [ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ] as const + ) { + let mcp = endpoint({ invoke: async () => ({ kind: "refused", code }) }); + let result = await call(mcp, "tools/call", { + name: "invoke_planner", + arguments: { id: "document-id", instruction: "go" }, + }); + expect(result.result).toMatchObject({ isError: true, structuredContent: { code } }); + } +}); diff --git a/apps/server/src/rooms.test.ts b/apps/server/src/rooms.test.ts new file mode 100644 index 00000000..c69e802a --- /dev/null +++ b/apps/server/src/rooms.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; + +import * as Rooms from "./rooms"; + +import type { Socket } from "./wire"; + +test("a Planner hold opens a memberless room, cancels eviction, and releases without affecting membership", () => { + let id = crypto.randomUUID(); + let first = Rooms.hold(id); + let room = first.room; + try { + expect(Rooms.get(id)).toBe(room); + expect(room.members.size).toBe(0); + expect(room.holds).toBe(1); + room.eviction = setTimeout(() => { + throw new Error("held room evicted"); + }, 10); + let second = Rooms.hold(id); + expect(second.room).toBe(room); + expect(room.eviction).toBeUndefined(); + expect(room.holds).toBe(2); + let socket = { data: { room: id, client: "client", handle: "ana" } } as Socket; + expect(Rooms.join(socket)).toBe(room); + first.release(); + first.release(); + expect(room.holds).toBe(1); + Rooms.leave(socket); + expect(room.members.size).toBe(0); + expect(room.holds).toBe(1); + second.release(); + expect(room.holds).toBe(0); + } finally { + Rooms.forget(room); + } +}); diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index 83405b7f..a6340fe6 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -31,6 +31,8 @@ export type Room = { closing?: Promise; /** Pending eviction, cancelled if somebody comes back. */ eviction?: ReturnType; + /** Local MCP work keeps a memberless room alive until its turn and queue finish. */ + holds?: number; }; const rooms = new Map(); @@ -47,6 +49,22 @@ export function get(id: string): Room | undefined { return rooms.get(id); } +export function hold(id: string): { room: Room; release: () => void } { + let room = open(id); + if (room.eviction) clearTimeout(room.eviction); + room.eviction = undefined; + room.holds = (room.holds ?? 0) + 1; + let released = false; + return { + room, + release() { + if (released) return; + released = true; + room.holds = room.holds! - 1; + }, + }; +} + export function join(ws: Socket): Room { let room = open(ws.data.room); // A reload is a departure followed by an arrival. Cancelling here is what diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 9902716a..d90b86ba 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -122,6 +122,13 @@ authenticity of a remote host. A per-invocation candidate takes precedence over the configured list. No verified checkout means the same isolated session as before, with no Atomic resources or HostInput binding. +A local coding agent can supply that candidate with +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). +The tool requires a live browser login matching its MCP caller, posts a durable +member instruction, and returns the document URL without waiting for the turn. +An explicitly supplied but unverified checkout is refused before posting, rather +than falling back to another configured path. + With a verified checkout, it becomes the session's working directory. Atomic's workflows, subagents, MCP, web access, Intercom, and default coding tools run alongside Chopin's document tools. The normal Atomic agent directory (including diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index ee3d63e3..9b6c24f3 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -24,9 +24,10 @@ authorization. The MCP bearer boundary is separate from Chopin's browser GitHub App session. Chopin authenticates the supplied token, applies the instance admission policy, and asks GitHub directly for that token's repository permissions. The GitHub App -for Chopin does not need to be installed on a repository for MCP access. Browser -routes, WebSockets, and the hosted agent still require an active App -installation that includes the repository. +for Chopin does not need to be installed for ordinary MCP document operations. +Browser routes, WebSockets, and the Planner still require an active App +installation that includes the repository. The local-only `invoke_planner` +handoff uses an existing browser login for that Planner boundary, not the MCP bearer. ```bash export CHOPIN_URL="https://your-chopin-instance.example" @@ -54,6 +55,8 @@ The current MCP contract can: - claim a graph and report task, pull-request, blocker, revision, and verification lifecycle transitions. +Local instances with `ATOMIC_PLANNER=full` also offer `invoke_planner`, described below. + Chopin validates the shape of `baseBranch` and `baseCommit` during creation but does not resolve them against GitHub. The creating agent is responsible for reading those values from its checkout rather than asserting arbitrary input. @@ -69,6 +72,58 @@ graph, but the product has no user-facing way to approve the Planner's draft. See [Experimental implementation lifecycle](implementation-lifecycle.md). +## Hand an instruction to the local Planner + +`invoke_planner` is available only with `ATOMIC_PLANNER=full`, which requires +`AUTH_MODE=local` and `HARNESS=atomic`. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for setup +and the trust boundary. A coding agent using this tool can drive a Planner with +**shell and filesystem access as the local user**. Keep the instance loopback-only +and single-operator; do not expose it to untrusted clients. + +Sign in to Chopin through the local device flow as the same GitHub account used +by the MCP bearer. The most recently issued live browser session for that account +supplies the Planner claimant. An existing Planner owner must belong to that same +account; the MCP bearer never becomes the owner or supplies its credentials. +Both the bearer and the browser login need repository write access, and the +browser login must pass the GitHub App installation check. + +Call the tool with an existing document's UUID or canonical URL, an instruction, +and optionally an absolute checkout path: + +```json +{ + "id": "/documents/octo-org/score/release-readiness", + "instruction": "Run the planning workflow and ask blocking questions in Decisions.", + "checkout": "/absolute/path/to/score" +} +``` + +The instruction must contain non-whitespace text and fit within 64 KiB in UTF-8; +its text is preserved verbatim. A supplied checkout must have an `origin` matching +the document repository or the request is refused before posting. The verified +path applies to this turn only, including when queued. If omitted, the configured +`ATOMIC_PLANNER_CHECKOUTS` list applies; without a verified checkout the Planner +keeps its isolated behavior. + +The result contains `id`, `title`, and the canonical `url` (plus any generated +`description`). It returns after the instruction is durably posted, not after the +Planner finishes. The message appears in Chat under the local user's handle and +joins the existing Planner queue if busy. Progress and results appear in the +document and Chat; Atomic questions appear in Decisions. The browser need not +already have the document open: Chopin keeps its room alive while the invoked +turn and queue run. An interrupted turn is not replayed automatically on restart. + +Refusals return an object with `code`: + +- `planner-owner-unavailable`: sign in locally as the MCP account and check the + existing Planner owner and browser repository access. +- `checkout-unverified`: correct the supplied path or its repository origin. +- `planner-unavailable`: the Planner is disabled or the room is closing. +- `planner-queue-full`: wait for queued work to drain before invoking again. +- `document-unavailable`, `document-archived`, and `repository-forbidden` retain + their ordinary document/access meanings. + ## Document URLs and IDs The `url` returned by `create_document` is the readable canonical route: @@ -78,8 +133,8 @@ The `url` returned by `create_document` is the readable canonical route: ``` `read_document`, `read_implementation`, `archive_document`, `restore_document`, -and `update_document` accept either a document UUID or that canonical URL in their -`id` input. The URL may be passed back exactly as returned; an absolute URL must +`update_document`, and (in full mode) `invoke_planner` accept either a document UUID +or that canonical URL in their `id` input. The URL may be passed back exactly as returned; an absolute URL must use the configured Chopin origin. Both reads return the stable UUID as the document `id`. @@ -222,9 +277,10 @@ token's `read:org` or Members access, SSO authorization, and GitHub availability lacks the operation's repository permission; it does not mean the GitHub App for Chopin must be installed. Pull access is enough for `list_documents`, `read_document`, and `read_implementation`. Pull plus push or admin access is -required for create, update, rename, archive, restore, start, and report lifecycle -operations. Use an account with the required access or ask a repository owner to -grant it. +required for create, update, rename, archive, restore, invoke, start, and report +lifecycle operations. `invoke_planner` additionally requires the local browser +login described above. Use an account with the required access or ask a repository +owner to grant it. Use the optional [creating-chopin-plans skill](../skills/creating-chopin-plans/SKILL.md) to turn a diff --git a/docs/self-hosting.md b/docs/self-hosting.md index af059da9..453578f4 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -453,6 +453,13 @@ Cancellation or timeout withdraws pending cards and never approves an action. See [Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for mapping, resource loading, persistence, and workflow restart boundaries. +This mode also exposes the MCP +[`invoke_planner` handoff](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). +Sign in locally as the MCP caller before using it. A supplied `checkout` is verified +before the instruction is posted; an invalid explicit path refuses the invocation. +The tool returns a document URL immediately while the Planner continues in Chat +and asks questions in Decisions. + ## First-start smoke test Startup validates configuration, database connectivity, migration history, the From ea47df091338c293f935e405f823c2a5dd0e5924 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 21:51:05 -0700 Subject: [PATCH 10/17] Present every Atomic host input in Decisions Return a written answer as Atomic's custom kind only where its own dialog accepts typed text, and as typed chat on multi-select or preview questions, so Atomic's validator accepts every answer a member can give. Hold verbatim host input only to aggregate bounds. Normalization and the shared draft skip per-field counts and lengths for verbatim questions, the dialect bounds questionnaire text by the source size, and an ask that would take the document past 256 KiB fails before any card or record exists. The Planner's ask tool keeps its per-field limits. Read the Atomic checkout and timeout settings only in full mode, treating empty values as unset. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: fail-before/pass-after passed: the 9 new tests failed with source changes stashed (InvalidHostInput, per-field QuestionErrors, config throw) and 93 of 93 passed after Assistant-verification: bun test passed: 1727 pass, 2 PostgreSQL skips, 0 fail, 7393 assertions across 196 files Assistant-verification: focused bun test passed: 294 tests in harness, question, dialect and config, including a full Atomic session answered through Decisions Assistant-verification: aggregate-bound mutation check passed: disabling the document fit check made the oversize dialog test time out; source restored byte-for-byte Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix and bun run ci passed: formatting inspected; no errors, one existing lint warning and no new design findings Assistant-verification: relative documentation links passed: 27 links and anchors resolved with exact casing User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- apps/server/src/config.test.ts | 31 +++ apps/server/src/config.ts | 20 +- apps/server/src/harness/atomic/full.test.ts | 46 +++- .../src/harness/atomic/human-input.test.ts | 250 ++++++++++-------- apps/server/src/harness/atomic/human-input.ts | 4 +- apps/server/src/plan/room.ts | 13 + apps/server/src/questions/service.ts | 51 ++-- apps/server/src/testing/decisions.ts | 128 +++++++++ docs/hosted-agent.md | 28 +- docs/self-hosting.md | 11 +- packages/dialect/src/dialect.test.ts | 14 + packages/dialect/src/dialect.ts | 10 +- packages/dialect/src/index.ts | 1 + packages/dialect/src/limits.ts | 16 +- packages/question/src/draft.ts | 2 +- packages/question/src/limits.ts | 4 +- packages/question/src/question.test.ts | 52 +++- .../question/src/react/question-view.test.ts | 23 ++ packages/question/src/schema.ts | 11 +- 19 files changed, 545 insertions(+), 170 deletions(-) create mode 100644 apps/server/src/testing/decisions.ts diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 65aa8dd7..0faa5e04 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -182,6 +182,37 @@ describe("configuration", () => { } }); + it("ignores Atomic Planner settings outside full mode and treats empty values as unset", () => { + for (let planner of [undefined, ""]) { + for (let [checkouts, timeout] of [["", ""], ["relative", "oops"]]) { + expect( + configured({ + ATOMIC_PLANNER: planner, + ATOMIC_PLANNER_CHECKOUTS: checkouts, + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout, + }).atomicPlanner, + ).toBeUndefined(); + } + } + let full = { + ATOMIC_PLANNER: "full", + AUTH_MODE: "local", + HARNESS: "atomic", + MODEL: "stub/model", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", + }; + for (let checkouts of [undefined, ""]) { + expect( + configured({ + ...full, + ATOMIC_PLANNER_CHECKOUTS: checkouts, + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "", + }).atomicPlanner, + ).toEqual({ checkouts: [] }); + } + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 822bbc67..b2a645ae 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -70,13 +70,19 @@ export function harnessSelection(): Pick< if (full && (process.env.AUTH_MODE !== "local" || process.env.HARNESS !== "atomic")) { throw new Error("ATOMIC_PLANNER=full requires AUTH_MODE=local and HARNESS=atomic"); } - let checkouts = process.env.ATOMIC_PLANNER_CHECKOUTS?.split(delimiter) ?? []; + let selection = { + host: process.env.SERVER_HOST || "127.0.0.1", + harness: process.env.HARNESS || "copilot-sdk", + harnessAuth: process.env.HARNESS_AUTH || undefined, + }; + if (!full) return selection; + let checkouts = (process.env.ATOMIC_PLANNER_CHECKOUTS || undefined)?.split(delimiter) ?? []; if (checkouts.some(path => !isAbsolute(path))) { throw new Error( "ATOMIC_PLANNER_CHECKOUTS must contain absolute paths separated by the platform path delimiter", ); } - let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS; + let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS || undefined; let inputTimeoutMs = rawTimeout === undefined ? undefined : Number(rawTimeout); if ( inputTimeoutMs !== undefined @@ -86,14 +92,8 @@ export function harnessSelection(): Pick< throw new Error("ATOMIC_PLANNER_INPUT_TIMEOUT_MS must be an integer between 1 and 2147483647"); } return { - host: process.env.SERVER_HOST || "127.0.0.1", - harness: process.env.HARNESS || "copilot-sdk", - harnessAuth: process.env.HARNESS_AUTH || undefined, - ...(full - ? { - atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, - } - : {}), + ...selection, + atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, }; } diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 9330beee..7dd8aa51 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -12,6 +12,7 @@ import { } from "./adapter"; import { registerFullPlanner } from "./full"; import { startStubModelServer } from "../pi/model-stub"; +import { hostInputRoom } from "../../testing/decisions"; import type { HostInput, QuestionParams } from "@bastani/atomic"; let params: QuestionParams = { @@ -24,18 +25,43 @@ let params: QuestionParams = { ], }], }; +let freeText: Record = { + multiple: { + questions: [{ + header: "Scope", + question: "Which areas?", + multiSelect: true, + options: [ + { label: "Server", description: "Back end" }, + { label: "Web", description: "Front end" }, + ], + }], + }, + preview: { + questions: [{ + header: "Layout", + question: "Which layout?", + options: [ + { label: "Rows", description: "Stacked", preview: "row\nrow" }, + { label: "Columns", description: "Side by side", preview: "col | col" }, + ], + }], + }, +}; let stub = startStubModelServer((prompt, prior) => prior ? { kind: "text", text: "Answered." } : prompt === "question" ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(params) } + : freeText[prompt] + ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(freeText[prompt]) } : prompt === "cwd" ? { kind: "tool", name: "bash", arguments: JSON.stringify({ command: "pwd" }) } : { kind: "text", text: "Ready." } ); afterAll(stub.stop); -async function run(full: boolean, registered: boolean, prompt = "plain") { +async function run(full: boolean, registered: boolean, prompt = "plain", host?: HostInput) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); let agentDir = join(root, "agent"); let cwd = join(root, "checkout"); @@ -86,7 +112,7 @@ async function run(full: boolean, registered: boolean, prompt = "plain") { let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { stdout: "pipe", stderr: "pipe" }); expect(await git.exited).toBe(0); process.env.ATOMIC_CODING_AGENT_DIR = agentDir; - let humanInput: HostInput = { + let humanInput: HostInput = host ?? { confirm: async () => false, select: async () => undefined, input: async () => undefined, @@ -179,6 +205,22 @@ test("full sessions use the checkout cwd and route ask_user_question through Hos expect(prompt.requests[0]!.prompt).toBe("OPERATOR-PROMPT-MARKER"); }); +test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { + let room = await hostInputRoom(); + try { + for (let prompt of ["multiple", "preview"]) { + let turn = run(true, true, prompt, room.input); + let [card] = await room.cards(1); + await room.answer(card!.id, ` typed ${prompt}\n`); + let output = (await turn).requests.at(-1)!.toolResults.join("\n"); + expect(output).toContain(` typed ${prompt}\n`); + expect(output).not.toContain("InvalidHostInput"); + } + } finally { + await room.close(); + } +}); + test("workers without a verified registration and default-mode sessions stay isolated", async () => { for (let [full, registered] of [[true, false], [false, true]]) { let result = await run(full!, registered!); diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index 6903c397..e7f3926c 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -1,16 +1,13 @@ import { afterEach, expect, test } from "bun:test"; -import * as Question from "@chopin/question"; +import { $nodesOfType } from "lexical"; +import { limits, QuestionnaireNode } from "@chopin/dialect"; import * as Questions from "../../questions/service"; import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; import * as Room from "../../plan/room"; -import { MemoryStorage } from "../../storage/memory/adapter"; import * as Edit from "../../plan/edit"; -import { createHumanInput } from "./human-input"; -import type { Server } from "bun"; -import type { HostInputOptions, QuestionParams } from "@bastani/atomic"; -import type { Socket, SocketData } from "../../wire"; -import type { DocumentRoom } from "../../agent/tools"; +import { hostInputRoom } from "../../testing/decisions"; +import type { QuestionParams } from "@bastani/atomic"; let cleanups: Array<() => Promise> = []; afterEach(async () => { @@ -18,102 +15,9 @@ afterEach(async () => { }); async function fixture(timeoutMs?: number) { - let storage = new MemoryStorage(); - let now = new Date(); - await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); - let channel = await storage.channels.create({ - id: crypto.randomUUID(), - repositoryId: "R_test", - repositoryOwner: "org", - repositoryName: "repo", - title: "Questions", - createdBy: "user", - now, - }); - let lease = (await storage.leases.acquire("writer", "test", 60_000))!; - let frames: any[] = []; - let server = { - publish(_topic: string, frame: string) { - frames.push(JSON.parse(frame)); - }, - } as unknown as Server; - let plan = await Plan.open(channel.id, { - storage, - lease: () => lease, - fatal: error => { - throw error; - }, - }, server); - let room = { - id: channel.id, - plan, - server, - anchors() {}, - } as DocumentRoom; - let ws = { - data: { handle: "reader", room: channel.id, client: "client" }, - send(frame: string) { - frames.push(JSON.parse(frame)); - }, - publish(_topic: string, frame: string) { - frames.push(JSON.parse(frame)); - }, - } as unknown as Socket; - cleanups.push(() => Plan.close(plan)); - let controller = new AbortController(); - let options: HostInputOptions = { - requestId: "request", - sessionId: "session", - signal: controller.signal, - }; - let input = createHumanInput(room, timeoutMs); - async function cards(count: number) { - await wait(() => Store.outstanding(plan.questions).length === count); - return Store.outstanding(plan.questions); - } - async function answer(id: string, value: number[] | string) { - let opened = Store.snapshot(plan.questions, id); - if (!opened.open) throw new Error("question closed"); - let definition = Store.get(plan.questions, id)!.definition; - let model = Question.restore(opened.model, definition); - let question = definition.questions[0]; - if (typeof value === "string") { - model.api.val([question.id, "mode"]).set("custom"); - if (value) model.api.str([question.id, "custom"]).ins(0, value); - } else if (question.multiple) { - for (let index of value) { - model.api.val([question.id, "options", question.options[index]!.id]).set(true); - } - } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); - let patch = model.api.flush(); - if (patch) { - await Questions.edit(plan, ws, { - kind: "question:edit", - rid: "edit", - ts: 0, - id, - patch: [...patch.toBinary()], - }); - } - let current = Store.snapshot(plan.questions, id); - if (!current.open) throw new Error("question closed"); - await Questions.submit(plan, server, channel.id, ws, { - kind: "question:submit", - rid: "submit", - ts: 0, - id, - revision: current.revision, - }); - } - return { input, plan, storage, frames, controller, options, cards, answer, ws, server, room }; -} - -async function wait(ready: () => boolean) { - for (let i = 0; i < 200; i++) { - if (ready()) return; - await Bun.sleep(5); - } - throw new Error("question did not arrive"); + let f = await hostInputRoom(timeoutMs); + cleanups.push(f.close); + return f; } let params: QuestionParams = { @@ -192,6 +96,36 @@ test("HostInput appends one ordered questionnaire batch and returns typed answer }); }); +test("Atomic's own HostInput validator accepts every answer a member can give", async () => { + let { HostInputBridge, getHostQuestionnaire } = await import( + new URL("./core/extensions/host-input.js", import.meta.resolve("@bastani/atomic")).href + ); + let f = await fixture(); + let bridge = new HostInputBridge(() => "session", () => undefined); + bridge.bind(f.input, "chopin"); + let questionnaire = getHostQuestionnaire(bridge.wrap({})); + let [preview, multiple, plain] = params.questions; + for ( + let [question, value, expected] of [ + [preview!, [1], { kind: "option", answer: "B" }], + [preview!, [0], { kind: "option", answer: " A ", preview: "preview A" }], + [preview!, " typed\n", { kind: "chat", answer: " typed\n" }], + [multiple!, [1, 0], { kind: "multi", answer: null, selected: ["C", "D"] }], + [multiple!, "several\n", { kind: "chat", answer: "several\n" }], + [plain!, [1], { kind: "option", answer: "F" }], + [plain!, " own ", { kind: "custom", answer: " own " }], + ] as const + ) { + let response = questionnaire({ questions: [question] }, new AbortController().signal); + let [card] = await f.cards(1); + await f.answer(card!.id, typeof value === "string" ? value : [...value]); + expect(await response).toEqual({ + cancelled: false, + answers: [{ questionIndex: 0, question: question.question, ...expected }], + }); + } +}); + test("maps confirm/select dialogs without treating custom text as approval or a selection", async () => { let f = await fixture(); for (let [value, approved] of [[[0], true], [[1], false], ["Yes", false]] as const) { @@ -367,3 +301,113 @@ test("blank dialog titles and supplied empty choices remain valid verbatim input expect(Room.project(restored)).toContain('value=""'); restored.doc.destroy(); }); + +/** Re-import the canonical document, proving the dialect accepts every card it holds. */ +async function documentQuestionnaires(source: string) { + let document = await Room.create(source); + try { + return document.editor.getEditorState().read(() => + $nodesOfType(QuestionnaireNode).map(node => node.getQuestionnaire().questions[0]!) + ); + } finally { + document.doc.destroy(); + } +} + +test("dialogs beyond the Planner's per-field limits become verbatim cards and answers", async () => { + let f = await fixture(); + let initial = "Keep this line of the plan.\n".repeat(180); + let edited = `${initial}${"Add this reviewed line.\n".repeat(60)}`; + let editor = f.input.editor("Edit plan", initial, f.options); + let [card] = await f.cards(1); + expect(initial.length).toBeGreaterThan(5_000); + expect(card!.definition.questions[0].question).toBe(`Edit plan\n\n${initial}`); + await f.answer(card!.id, edited); + expect(edited.length).toBeGreaterThan(6_000); + expect(await editor).toBe(edited); + + let message = "This workflow step rewrites generated files. ".repeat(30); + let confirmed = f.input.confirm("Proceed?", message, f.options); + [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await confirmed).toBe(true); + + let choices = Array.from({ length: 25 }, (_, index) => `Choice ${index}`); + choices[24] = `The long choice ${"x".repeat(250)}`; + let selected = f.input.select("Pick one", choices, f.options); + [card] = await f.cards(1); + expect(card!.definition.questions[0].options.map(option => option.label)).toEqual(choices); + await f.answer(card!.id, [24]); + expect(await selected).toBe(choices[24]); + + let rollout = "Explain the rollout, its owners, and its checks. ".repeat(40); + let asked = f.input.questionnaire({ + questions: [{ + header: "Rollout", + question: "Which rollout?", + options: [{ label: "Staged", description: rollout }, { label: "All", description: "" }], + }], + }, f.options); + [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect((await asked).answers[0]).toMatchObject({ kind: "option", answer: "Staged" }); + + expect(await documentQuestionnaires(Plan.source(f.plan))).toMatchObject([ + { header: "Editor", prompt: `Edit plan\n\n${initial}`, options: [], answer: edited }, + { header: "Confirm", prompt: `Proceed?\n\n${message}`, answer: "Yes" }, + { + header: "Select", + prompt: "Pick one", + options: choices.map(label => ({ label })), + answer: choices[24], + }, + { + header: "Rollout", + options: [{ label: "Staged", description: rollout }, { label: "All" }], + answer: "Staged", + }, + ]); +}); + +test("a workflow label never pushes a verbatim question into a rejection", async () => { + let f = await fixture(); + let question = "Should the review stage accept this change? ".repeat(23).slice(0, 990); + let long: QuestionParams = { + questions: [{ + header: "Review", + question, + options: [{ label: "Ship", description: "" }, { label: "Hold", description: "" }], + }], + }; + let response = f.input.questionnaire(long, { + ...f.options, + workflowRunId: "run-123", + workflowStageId: "review", + }); + let [card] = await f.cards(1); + let labelled = `${question}\n\nWorkflow run: run-123; stage: review`; + expect(card!.definition.questions[0].question).toBe(labelled); + expect(await documentQuestionnaires(Plan.source(f.plan))).toMatchObject([{ prompt: labelled }]); + await f.answer(card!.id, [0]); + expect(await response).toEqual({ + cancelled: false, + answers: [{ questionIndex: 0, question, kind: "option", answer: "Ship" }], + }); +}); + +test("a dialog that cannot fit in the document fails loudly without leaving a card", async () => { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: `${"Existing prose in the document. ".repeat(3_000)}\n`, + }]); + if (!edited.ok || !edited.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let before = Plan.source(f.plan); + await expect( + f.input.editor("Too large", "x".repeat(limits.MAX_SOURCE_BYTES - 90_000), f.options), + ).rejects.toThrow(`${limits.MAX_SOURCE_BYTES / 1024} KiB`); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.size).toBe(0); + expect(Plan.source(f.plan)).toBe(before); +}); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index a3812a0c..1f02910d 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -56,7 +56,9 @@ export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostIn let question = params.questions[questionIndex]!; let identity = { questionIndex, question: question.question }; if (answer.custom !== undefined) { - answers.push({ ...identity, kind: "custom", answer: answer.custom }); + // Atomic accepts `custom` only where its own dialog offers a typed row. + let typed = !question.multiSelect && !question.options.some(option => option.preview); + answers.push({ ...identity, kind: typed ? "custom" : "chat", answer: answer.custom }); } else if (question.multiSelect) { answers.push({ ...identity, kind: "multi", answer: null, selected: answer.choices ?? [] }); } else { diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index c00773f1..cb21a07b 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -26,6 +26,7 @@ import { limits, parse, PlanValidationError, + questionnaireElement, QuestionnaireNode, registry as buildRegistry, serialize, @@ -433,6 +434,18 @@ export function insertQuestionnaires( }, insertions); } +/** + * Whether appending these questionnaires keeps the source within its byte limit. + * + * Measured before mutating, because Yjs cannot undo an insertion that turns out + * to be too large. + */ +export function fitsQuestionnaires(target: Document, values: Questionnaire[]): boolean { + let tree = parse(project(target)); + tree.children.push(...values.map(questionnaireElement)); + return Buffer.byteLength(serialize(tree)) <= limits.MAX_SOURCE_BYTES; +} + /** Append one questionnaire to the plan. */ export function insertQuestionnaire(target: Document, value: Questionnaire): Mutation | undefined { return insertQuestionnaires(target, [{ value }]); diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index 296b2ed5..fee8d248 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -12,7 +12,7 @@ * everyone already knows how to render. */ -import { ulid } from "@chopin/dialect"; +import { limits, ulid } from "@chopin/dialect"; import * as Question from "@chopin/question"; import * as Anchors from "./anchors"; @@ -119,31 +119,40 @@ export async function ask( }> = []; await Service.exclusive(plan, async () => { let anchors = placement ? validatePlacement(plan, definition, placement) : undefined; + // Widget and question identities are deliberately distinct. + let values = definition.questions.map(question => ({ + id: ulid(), + questions: [{ + id: question.id, + header: question.header, + prompt: question.question, + multiple: question.multiple, + options: question.options.map(option => ({ + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + }], + })); + // Verbatim host input has no per-field limits, so the document bounds it. + if ( + definition.questions.some(question => question.verbatim) + && !room.fitsQuestionnaires(plan.document, values) + ) { + Question.reject( + `This input would take the document past its ${limits.MAX_SOURCE_BYTES / 1024} KiB limit`, + ); + } asked = definition.questions.map((question, index) => { let single = Question.decision({ questions: [question] }); - // Widget and question identities are deliberately distinct. - let id = ulid(); - let waiting = Store.ask(plan.questions, id, single, id); - let value = { - id, - questions: [{ - id: question.id, - header: question.header, - prompt: question.question, - multiple: question.multiple, - options: question.options.map(option => ({ - id: option.id, - label: option.label, - ...(option.description ? { description: option.description } : {}), - })), - }], - }; - let record: Record = { id, definition: single, status: "open" }; + let value = values[index]!; + let waiting = Store.ask(plan.questions, value.id, single, value.id); + let record: Record = { id: value.id, definition: single, status: "open" }; if (anchors) { record.anchors = Anchors.set(Anchors.read(record), question.id, anchors[index]!); } - plan.records.set(id, record); - return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; + plan.records.set(value.id, record); + return { id: value.id, single, value, waiting, at: placement?.blocks[index]?.[0] }; }); let mutation = room.insertQuestionnaires( diff --git a/apps/server/src/testing/decisions.ts b/apps/server/src/testing/decisions.ts new file mode 100644 index 00000000..2897838b --- /dev/null +++ b/apps/server/src/testing/decisions.ts @@ -0,0 +1,128 @@ +import * as Question from "@chopin/question"; + +import { createHumanInput } from "../harness/atomic/human-input"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { MemoryStorage } from "../storage/memory/adapter"; + +import type { Server } from "bun"; +import type { HostInputOptions } from "@bastani/atomic"; +import type { DocumentRoom } from "../agent/tools"; +import type { Socket, SocketData } from "../wire"; + +/** A room whose Decisions answer Atomic HostInput requests, with a member to answer them. */ +export async function hostInputRoom(timeoutMs?: number) { + let storage = new MemoryStorage(); + let now = new Date(); + await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_test", + repositoryOwner: "org", + repositoryName: "repo", + title: "Questions", + createdBy: "user", + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let frames: any[] = []; + let server = { + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Server; + let plan = await Plan.open(channel.id, { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }, server); + let room = { + id: channel.id, + plan, + server, + anchors() {}, + } as DocumentRoom; + let ws = { + data: { handle: "reader", room: channel.id, client: "client" }, + send(frame: string) { + frames.push(JSON.parse(frame)); + }, + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Socket; + let controller = new AbortController(); + let options: HostInputOptions = { + requestId: "request", + sessionId: "session", + signal: controller.signal, + }; + let input = createHumanInput(room, timeoutMs); + async function cards(count: number) { + for (let deadline = Date.now() + 4_000; Date.now() < deadline; await Bun.sleep(5)) { + if (Store.outstanding(plan.questions).length === count) { + return Store.outstanding(plan.questions); + } + } + throw new Error("question did not arrive"); + } + function refused(rid: string) { + let frame = frames.findLast(frame => frame.rid === rid); + if (frame?.accepted === false || frame?.ok === false || frame?.kind === "session:error") { + throw new Error(frame.message ?? frame.reason); + } + } + async function answer(id: string, value: number[] | string) { + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question closed"); + let definition = Store.get(plan.questions, id)!.definition; + let model = Question.restore(opened.model, definition); + let question = definition.questions[0]; + if (typeof value === "string") { + model.api.val([question.id, "mode"]).set("custom"); + if (value) model.api.str([question.id, "custom"]).ins(0, value); + } else if (question.multiple) { + for (let index of value) { + model.api.val([question.id, "options", question.options[index]!.id]).set(true); + } + } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + let patch = model.api.flush(); + if (patch) { + await Questions.edit(plan, ws, { + kind: "question:edit", + rid: "edit", + ts: 0, + id, + patch: [...patch.toBinary()], + }); + refused("edit"); + } + let current = Store.snapshot(plan.questions, id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(plan, server, channel.id, ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id, + revision: current.revision, + }); + refused("submit"); + } + return { + input, + plan, + storage, + frames, + controller, + options, + cards, + answer, + ws, + server, + room, + close: () => Plan.close(plan), + }; +} diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index d90b86ba..f94ad313 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -143,15 +143,22 @@ Chopin implements Atomic's `HostInput`, bound through `extensionBindings.humanInput`; it does not intercept tools. `ask_user_question`, extension dialogs, and workflow-stage input become ordinary shared Decisions: -- Questionnaires retain question and option order, multi-selection, and custom - text; answers return their Atomic question indices and answer kinds, including - a selected option's preview. +- Questionnaires retain question and option order, multi-selection, and the + exact text Atomic supplied; answers return their Atomic question indices and + answer kinds, including a selected option's preview. +- Every card offers a written answer. It returns as Atomic's `custom` kind where + Atomic's own dialog accepts typed text (single-select without previews), and + otherwise as typed `chat`, which Atomic delivers to the model as the member's + inline message (blank `chat` text is Atomic's plain request to talk first). + Chopin never reports written text as a chosen option, and Atomic workflows + treat `chat` as a request to keep discussing, not approval. - Confirm uses Yes/No; only choosing Yes approves. Select returns only a supplied choice. Input and editor use free-text cards; hints and initial editor text appear verbatim in the prompt. Explicitly submitted empty text is an answer, not cancellation. - Workflow cards show `Workflow run: ; stage: ` below their question. - A request is appended as one adjacent batch at the end of the document. + The label is added to the card only; Atomic receives its question text as + asked. A request is appended as one adjacent batch at the end of the document. - An abort withdraws still-open cards, removes their document nodes, and records cancellation by `@chopin` in Decisions. Member cancellations retain any answers already given in that batch. Late submissions cannot approve withdrawn input. @@ -159,10 +166,15 @@ extension dialogs, and workflow-stage input become ordinary shared Decisions: integer in milliseconds, at most 2147483647). Unset waits indefinitely. Expiry withdraws pending cards and returns no answer; confirm returns false. -Chopin's question size limits still apply: oversized dialogs fail rather than -silently truncating text. Atomic keeps durable workflow approvals pending on -withdrawal; Chopin does not automatically resume workflows or replay interrupted -turns after a restart. A new Atomic session must explicitly resume a saved run. +Host input is not held to the Planner `ask` tool's per-field limits on question +and option counts or header, question, label, description, and answer lengths. +Only Chopin's aggregate bounds apply. A request whose cards would take the +document past its 256 KiB source limit fails with that message before any card +appears. A written answer is limited by one shared-draft edit (64 KiB) and the +whole draft (256 KiB). Text is never silently truncated. Atomic keeps durable +workflow approvals pending on withdrawal; Chopin does not automatically resume +workflows or replay interrupted turns after a restart. A new Atomic session must +explicitly resume a saved run. ## Permission checks diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 453578f4..3c23bde0 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -258,8 +258,8 @@ the image. | `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | | `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | | `ATOMIC_PLANNER` | unset | Set exactly `full` to allow full Atomic Planner sessions. Requires `AUTH_MODE=local` and `HARNESS=atomic`; every other nonempty value or combination fails startup. Both Atomic auth modes are supported. | -| `ATOMIC_PLANNER_CHECKOUTS` | unset | Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. | -| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | +| `ATOMIC_PLANNER_CHECKOUTS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. With no list, only an `invoke_planner` checkout can enable a full session. | +| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | | `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | | `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | | `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | @@ -449,8 +449,11 @@ The check does not establish remote-host authenticity or confine shell access. Atomic input appears as shared Decisions instead of terminal dialogs, including workflow run/stage labels. Unanchored batches append at the document's end. -Cancellation or timeout withdraws pending cards and never approves an action. -See [Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) +Dialog text and written answers stay verbatim, bounded only by the document's +256 KiB source limit and the shared-draft limits rather than the Planner's own +question limits. Cancellation or timeout withdraws pending cards and never +approves an action. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for mapping, resource loading, persistence, and workflow restart boundaries. This mode also exposes the MCP diff --git a/packages/dialect/src/dialect.test.ts b/packages/dialect/src/dialect.test.ts index 80bd73f6..e492d8ac 100644 --- a/packages/dialect/src/dialect.test.ts +++ b/packages/dialect/src/dialect.test.ts @@ -290,6 +290,20 @@ describe("limits", () => { expect(codes(`y`)) .toContain("attribute-too-long"); }); + + it("bounds questionnaire projections only by the source size", () => { + let questionnaire = (text: string) => + `\n` + + `\n` + + `\n` + + ``; + accepts(questionnaire("x".repeat(5_000))); + let source = questionnaire("x".repeat(limits.MAX_SOURCE_BYTES)); + let result = validate(parse(source), { bytes: new TextEncoder().encode(source).byteLength }); + expect(result.ok ? [] : result.issues.map(issue => issue.code)).toEqual(["source-too-large"]); + }); }); describe("diagnostics", () => { diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index 83c62c14..33233e59 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -110,8 +110,8 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "components", names: ["Option", "Answer"] }, parent: ["Questionnaire"], attributes: { - header: { type: "text", required: true, max: limits.MAX_QUESTION_HEADER }, - prompt: { type: "text", required: true, max: limits.MAX_QUESTION_PROMPT }, + header: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, + prompt: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, multiple: { type: "enum", required: true, values: ["true", "false"] }, }, }), @@ -122,8 +122,8 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "empty" }, parent: ["Question"], attributes: { - label: { type: "text", required: true, max: limits.MAX_OPTION_LABEL }, - description: { type: "text", required: false, max: limits.MAX_OPTION_DESCRIPTION }, + label: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, + description: { type: "text", required: false, max: limits.MAX_QUESTIONNAIRE_TEXT }, }, }), @@ -137,7 +137,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "empty" }, parent: ["Question"], attributes: { - value: { type: "text", required: true, max: limits.MAX_CUSTOM_ANSWER }, + value: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, }, }), diff --git a/packages/dialect/src/index.ts b/packages/dialect/src/index.ts index 721b9dfc..8b2d659f 100644 --- a/packages/dialect/src/index.ts +++ b/packages/dialect/src/index.ts @@ -74,6 +74,7 @@ export { $createQuestionnaireNode, $isQuestionnaireNode, QuestionnaireNode, + toElement as questionnaireElement, } from "./nodes/questionnaire"; export type { Option, Question, Questionnaire } from "./nodes/questionnaire"; diff --git a/packages/dialect/src/limits.ts b/packages/dialect/src/limits.ts index 0fbf44ea..69ef1645 100644 --- a/packages/dialect/src/limits.ts +++ b/packages/dialect/src/limits.ts @@ -26,14 +26,14 @@ export const MAX_IMAGES = 100; export const MAX_TAB_LABEL = 60; export const MAX_CALLOUT_TITLE = 100; -/** Questionnaire shape, matching the `ask` tool's contract. */ -export const MAX_QUESTIONS = 10; -export const MAX_OPTIONS = 20; -export const MAX_QUESTION_HEADER = 80; -export const MAX_QUESTION_PROMPT = 1_000; -export const MAX_OPTION_LABEL = 200; -export const MAX_OPTION_DESCRIPTION = 1_000; -export const MAX_CUSTOM_ANSWER = 4_000; +/** + * Questionnaire text, a projection of its server-owned record. + * + * The `ask` tool bounds each field of its own questions; a verbatim host dialog + * keeps whatever its runtime permitted. The Planner cannot author these + * components, so the source size is the only bound the document adds. + */ +export const MAX_QUESTIONNAIRE_TEXT = MAX_SOURCE_BYTES; /** * Accepted comment threads, projected into the plan as ``. diff --git a/packages/question/src/draft.ts b/packages/question/src/draft.ts index 0db10b8b..b572748b 100644 --- a/packages/question/src/draft.ts +++ b/packages/question/src/draft.ts @@ -149,7 +149,7 @@ export function read(model: Model, definition: Definition): Drafts { let custom = node.get("custom"); if (!(custom instanceof crdt.StrNode)) reject(`${question.id}.custom must be a CRDT string`); let value = (custom as crdt.StrNode).view(); - if (value.length > limits.MAX_CUSTOM) { + if (!question.verbatim && value.length > limits.MAX_CUSTOM) { reject(`${question.id}.custom exceeds ${limits.MAX_CUSTOM} characters`); } diff --git a/packages/question/src/limits.ts b/packages/question/src/limits.ts index 7a3fb939..e53e1ced 100644 --- a/packages/question/src/limits.ts +++ b/packages/question/src/limits.ts @@ -2,7 +2,9 @@ * Bounds on a questionnaire. * * Enforced by the VM when the agent asks a question and again on every edit. - * Clients apply the same numbers so a rejection is never a surprise. + * Clients apply the same numbers so a rejection is never a surprise. Verbatim + * host dialogs skip the per-field counts and lengths; the byte limits below + * still apply to them. */ export const MAX_QUESTIONS = 10; diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index 792f5a9a..37b9ea52 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { derive, incomplete, summarize } from "./answer"; -import { answered, apply, assertPatch, crdt, create, read } from "./draft"; +import { answered, apply, assertPatch, crdt, create, read, restore } from "./draft"; import * as limits from "./limits"; import { appendOption, normalize, QuestionError } from "./schema"; @@ -67,8 +67,54 @@ describe("normalize", () => { it("enforces the documented limits", () => { expect(() => normalize({ questions: Array.from({ length: 11 }, () => tool().questions[0]) })) .toThrow(/at most 10 questions/); - expect(() => normalize(tool({ header: "x".repeat(limits.MAX_HEADER + 1) }))) - .toThrow(/exceeds 80 characters/); + let many = Array.from({ length: limits.MAX_OPTIONS + 1 }, (_, index) => ({ + label: `${index}`, + description: "", + })); + for ( + let [override, message] of [ + [{ header: "x".repeat(limits.MAX_HEADER + 1) }, /exceeds 80 characters/], + [{ question: "x".repeat(limits.MAX_QUESTION + 1) }, /exceeds 1000 characters/], + [{ options: many }, /at most 20 options/], + [{ options: [{ label: "x".repeat(limits.MAX_LABEL + 1), description: "" }] }, /label/], + [ + { options: [{ label: "a", description: "x".repeat(limits.MAX_DESCRIPTION + 1) }] }, + /description exceeds 1000 characters/, + ], + ] as const + ) expect(() => normalize(tool(override))).toThrow(message); + let model = create(normalize(tool())); + model.api.val(["q0", "mode"]).set("custom"); + model.api.str(["q0", "custom"]).ins(0, "x".repeat(limits.MAX_CUSTOM + 1)); + expect(() => read(model, normalize(tool()))).toThrow(/custom exceeds 4000 characters/); + }); + + it("bounds verbatim host input only by the shared draft's byte limits", () => { + let question = { + header: "x".repeat(limits.MAX_HEADER + 1), + question: "x".repeat(limits.MAX_QUESTION + 1), + multiple: false, + options: Array.from({ length: limits.MAX_OPTIONS + 1 }, (_, index) => ({ + label: `${index}`.padEnd(limits.MAX_LABEL + 1, " "), + description: "x".repeat(limits.MAX_DESCRIPTION + 1), + })), + }; + let definition = normalize({ + questions: Array.from({ length: limits.MAX_QUESTIONS + 1 }, () => question), + }, { verbatim: true }); + expect(definition.questions).toHaveLength(limits.MAX_QUESTIONS + 1); + expect(definition.questions[0]).toMatchObject({ + header: question.header, + question: question.question, + options: question.options, + }); + let custom = "x".repeat(limits.MAX_CUSTOM + 1); + let model = create(definition); + model.api.val(["q0", "mode"]).set("custom"); + model.api.str(["q0", "custom"]).ins(0, custom); + expect(read(model, definition).q0!.custom).toBe(custom); + model.api.str(["q0", "custom"]).ins(0, "x".repeat(limits.MAX_MODEL_BYTES)); + expect(() => restore([...model.toBinary()], definition)).toThrow(/256 KiB limit/); }); it("requires multiple to be a boolean", () => { diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index 1252ac1e..d2825dd0 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -240,3 +240,26 @@ test("a free-text host dialog renders an editable textarea without inventing a c expect(markup).toContain("Write the brief"); expect(markup).not.toContain('disabled=""'); }); + +test("only the Planner's own questions cap the custom answer textarea", () => { + let question = { header: "Input", question: "Write the brief", multiple: false }; + for ( + let [definition, cap] of [ + [normalize({ questions: [{ ...question, options: [] }] }, { verbatim: true }), false], + [ + normalize({ questions: [{ ...question, options: [{ label: "A", description: "" }] }] }), + true, + ], + ] as const + ) { + let drafts = read(create(definition), definition); + drafts.q0!.mode = "custom"; + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts, + onChange() {}, + })); + expect(markup).toContain(" max) fail(`${name} exceeds ${max} characters`); return result; @@ -66,6 +67,10 @@ function text( * unique within one call. Durable plan questionnaires re-key them to ULIDs on * the way in, since those do have to survive rewrites. * + * A verbatim definition is a host dialog the runtime already permitted: it + * keeps its text exactly and is bounded by the draft and document byte limits + * rather than by the `ask` tool's per-field contract. + * * @throws {QuestionError} */ export function normalize(raw: unknown, { verbatim = false } = {}): Definition { @@ -75,7 +80,7 @@ export function normalize(raw: unknown, { verbatim = false } = {}): Definition { if (!Array.isArray(args.questions) || args.questions.length === 0) { fail("At least one question is required"); } - if (args.questions.length > limits.MAX_QUESTIONS) { + if (!verbatim && args.questions.length > limits.MAX_QUESTIONS) { fail(`A questionnaire can contain at most ${limits.MAX_QUESTIONS} questions`); } @@ -86,7 +91,7 @@ export function normalize(raw: unknown, { verbatim = false } = {}): Definition { if (!Array.isArray(raw.options) || (!verbatim && raw.options.length === 0)) { fail(`Question ${index + 1} requires at least one option`); } - if (raw.options.length > limits.MAX_OPTIONS) { + if (!verbatim && raw.options.length > limits.MAX_OPTIONS) { fail(`Question ${index + 1} can contain at most ${limits.MAX_OPTIONS} options`); } if (typeof raw.multiple !== "boolean") { From 7daf9966cbd652f4b30fe33d3b6327e318bcd263 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 22:22:07 -0700 Subject: [PATCH 11/17] Keep the first input in an empty custom answer Replacing a custom answer always deleted the current text first. On an empty answer json-joy 17 binary-encodes that zero-length delete so the server decodes garbage that swallows the following insert, yet still acknowledges the edit. A paste, fill or one-character answer into the empty box was lost, and a verbatim host question then saved "". Delete only existing text, and skip the insert for an empty value, which json-joy refuses: clearing a typed answer threw after its delete. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: fail-before/pass-after passed: with the unfixed change() the 5 new controller tests failed (the server-applied draft kept custom "" after one input for verbatim and ordinary questions and a host-only paste, and clearing threw EMPTY_STRING); 12 of 12 passed after Assistant-verification: bun test passed: 1732 pass, 2 PostgreSQL skips, 0 fail, 7402 assertions across 196 files Assistant-verification: bun test packages/question passed: 35 pass, 0 fail Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix passed: no change to tracked files; one existing lint warning Assistant-verification: bun run ci failed: dprint flagged only three untracked .atomic/todos files; dprint check excluding .atomic, oxlint, token, type-scale and design checks then passed User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- .../src/react/use-questionnaire.test.ts | 96 ++++++++++++++++++- .../question/src/react/use-questionnaire.ts | 7 +- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/question/src/react/use-questionnaire.test.ts b/packages/question/src/react/use-questionnaire.test.ts index 96a2eec1..a61879ea 100644 --- a/packages/question/src/react/use-questionnaire.test.ts +++ b/packages/question/src/react/use-questionnaire.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { create, normalize } from "../index"; +import { apply, create, normalize, read } from "../index"; import { FocusReporter, QuestionnaireController } from "./use-questionnaire"; +import type { Definition } from "../index"; import type { Transport } from "./use-questionnaire"; const DEFINITION = normalize({ @@ -59,6 +60,12 @@ function transport(definition = DEFINITION) { } if (kind === "question:edit") { edits++; + // The server's own gate: an edit counts only if its wire bytes apply. + let outcome = apply(model, definition, payload.patch as number[]); + if (!outcome.ok) { + return { open: true, accepted: false, revision: edits, message: outcome.message }; + } + model = outcome.model; return { open: true, accepted: true, applied: false, revision: edits }; } if (kind === "question:submit") { @@ -92,9 +99,22 @@ function transport(definition = DEFINITION) { submitRevisions: () => submitRevisions, presence: () => presence, frames: () => frames, + drafts: () => read(model, definition), }; } +async function open(definition: Definition) { + let bridge = transport(definition); + let controller = new QuestionnaireController(bridge.value, "question-1", definition, true); + let off = controller.subscribe(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + return { bridge, controller, off }; +} + +function settle() { + return new Promise(resolve => setTimeout(resolve, 0)); +} + describe("QuestionnaireController", () => { it("rejects a questionnaire returned for an independent decision record", async () => { let bridge = transport(QUESTIONNAIRE); @@ -254,3 +274,77 @@ describe("FocusReporter", () => { expect(sent.slice(3)).toEqual(["b", "c"]); }); }); + +describe("custom answer edits", () => { + let text = " pasted in one input,\nwith spacing kept "; + + for (let verbatim of [false, true]) { + it(`stores one input into an empty custom answer (verbatim: ${verbatim})`, async () => { + let definition = normalize({ + questions: [{ + header: "Scope", + question: "Which areas?", + multiple: true, + options: [{ label: "Server", description: "" }, { label: "Web", description: "" }], + }], + }, { verbatim }); + let { bridge, controller, off } = await open(definition); + + controller.change("q0", { mode: "custom" }); + await settle(); + controller.change("q0", { custom: text }); + await settle(); + + expect(controller.getSnapshot().error).toBeUndefined(); + expect(bridge.drafts().q0).toMatchObject({ mode: "custom", custom: text }); + off(); + }); + } + + it("stores a paste into a host dialog that has only a custom answer", async () => { + let definition = normalize({ + questions: [{ header: "Editor", question: "Edit the notes", multiple: false, options: [] }], + }, { verbatim: true }); + let { bridge, controller, off } = await open(definition); + let long = "notes line\n".repeat(500); + + controller.change("q0", { custom: long }); + await settle(); + + expect(bridge.drafts().q0?.custom).toBe(long); + off(); + }); + + it("replaces a non-empty custom answer", async () => { + let { bridge, controller, off } = await open(DEFINITION); + + controller.change("q0", { mode: "custom" }); + await settle(); + controller.change("q0", { custom: "first" }); + await settle(); + expect(bridge.drafts().q0?.custom).toBe("first"); + controller.change("q0", { custom: text }); + await settle(); + + expect(bridge.drafts().q0?.custom).toBe(text); + off(); + }); + + it("submits a verbatim answer that was typed and then cleared", async () => { + let definition = normalize({ + questions: [{ header: "Input", question: "Name?", multiple: false, options: [] }], + }, { verbatim: true }); + let { bridge, controller, off } = await open(definition); + + controller.change("q0", { custom: "typed" }); + await settle(); + controller.change("q0", { custom: "" }); + await settle(); + expect(bridge.drafts().q0?.custom).toBe(""); + + controller.submit(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(bridge.submits()).toBe(1); + off(); + }); +}); diff --git a/packages/question/src/react/use-questionnaire.ts b/packages/question/src/react/use-questionnaire.ts index 27c402a7..1e47e326 100644 --- a/packages/question/src/react/use-questionnaire.ts +++ b/packages/question/src/react/use-questionnaire.ts @@ -207,8 +207,11 @@ export class QuestionnaireController { } if (patch.custom !== undefined) { let value = doc.api.str([question, "custom"]); - value.del(0, value.length()); - value.ins(0, patch.custom as string); + let text = patch.custom as string; + // json-joy 17 binary-encodes a zero-length delete so that the receiver decodes + // garbage that swallows the following insert, and it throws on an empty insert. + if (value.length() > 0) value.del(0, value.length()); + if (text) value.ins(0, text); } }); }; From 2fe87c6ba2b320c52d6f4454a680c9efaaed8c5d Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Tue, 29 Sep 2026 00:08:24 -0700 Subject: [PATCH 12/17] Run every atomic Planner session as a full Atomic session HARNESS=atomic is now the only switch. Every Planner session on that harness, local or hosted, runs as a full Atomic session: builtins, default coding tools, the operator's Atomic resources, and Chopin bound as HostInput. The summary and research workers keep their isolated sessions, and copilot-sdk and pi are unchanged. This supersedes the local-only opt-in from c84fa4e. Its environment variables and Config.atomicPlanner are gone, and nothing replaces them. A checkout now comes only from invoke_planner. On the atomic harness it is verified against the document's origin before anything is posted, then remembered in memory for that channel. Every later Planner session re-verifies it before use, whether it starts from the browser or MCP. Without a remembered checkout, the session runs with the same tools in an empty 0700 directory created for that channel alone and removed at shutdown. The Planner's instructions say which case applies. Host input expires after a fixed 30 minutes. The questionnaire record and question:resolved now carry an "expired" status. The card stays in the document with status="expired" and is listed among the resolved Decisions, with a note that nobody answered and the Planner will use its best judgement. Atomic receives no answer, and late submissions are refused. An abort or member cancel still withdraws cards as before. invoke_planner is offered for every harness and auth mode. The posted message is attributed to the caller. The turn runs under the channel's existing owner, or the caller's live browser login claims ownership, or the call is refused before anything is posted. Other harnesses ignore the checkout. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: bun test passed: 1746 pass, 2 PostgreSQL skips, 0 fail, 7619 assertions across 196 files Assistant-verification: focused bun test (harness, mcp, chat, config, question, protocol) passed: 390 pass, 0 fail, 1821 assertions across 38 files Assistant-verification: worker isolation mutations passed: BUILTINS_OFF set true failed 17 of 34, isolated skill discovery failed 2, the result tool offered on plain turns failed 11; the adapter was restored byte-for-byte Assistant-verification: behaviour mutations passed: never registering atomic sessions, withdrawing on expiry, rejecting expired records on restore, and refusing an owner other than the caller each failed their new tests; every file was restored byte-for-byte Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix passed: formatted changed files and one untracked .atomic todo, which was restored byte-for-byte; one existing lint warning Assistant-verification: bun run ci failed: dprint flagged only one untracked .atomic/todos file; dprint check on the tracked and new files, oxlint, token, type-scale and design checks then passed Assistant-verification: relative-link check passed: every relative link and anchor in README.md, AGENTS.md and docs resolves with exact casing User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" User-preference: No extra per-harness flags; HARNESS=atomic always runs the Planner as a full Atomic session, hosted included User-preference: No extra per-harness env settings; the checkout comes from invoke_planner and the input timeout is a code constant Co-authored-by: Alex Lavaee --- AGENTS.md | 25 +- README.md | 11 +- apps/server/src/agent/planner.ts | 31 +- apps/server/src/auth/session.ts | 2 +- apps/server/src/chat/invoke.test.ts | 310 ++++++++++++++---- apps/server/src/chat/service.ts | 88 +++-- apps/server/src/config.test.ts | 139 +------- apps/server/src/config.ts | 38 +-- .../src/harness/atomic.contract.test.ts | 10 +- .../server/src/harness/atomic/adapter.test.ts | 26 +- apps/server/src/harness/atomic/adapter.ts | 11 +- .../src/harness/atomic/checkout.test.ts | 13 +- apps/server/src/harness/atomic/checkout.ts | 53 ++- apps/server/src/harness/atomic/full.test.ts | 102 ++++-- apps/server/src/harness/atomic/full.ts | 2 +- .../src/harness/atomic/human-input.test.ts | 108 ++++-- apps/server/src/harness/atomic/human-input.ts | 43 +-- apps/server/src/harness/atomic/workspace.ts | 67 ++++ apps/server/src/harness/harnesses.ts | 13 +- apps/server/src/harness/session.test.ts | 152 ++++++--- apps/server/src/harness/session.ts | 25 +- apps/server/src/main.ts | 55 ++-- apps/server/src/mcp.ts | 11 +- apps/server/src/mcp/hosted.test.ts | 171 ++++------ apps/server/src/mcp/hosted.ts | 21 +- apps/server/src/plan/room.ts | 13 + apps/server/src/plan/service.ts | 3 +- apps/server/src/questions/service.ts | 91 +++-- apps/server/src/questions/store.ts | 8 +- apps/server/src/rooms.ts | 2 +- apps/server/src/testing/config.ts | 49 +++ apps/server/src/testing/decisions.ts | 4 +- apps/web/src/room-workspace.tsx | 5 +- docs/architecture.md | 7 +- docs/authentication.md | 16 +- docs/hosted-agent.md | 168 ++++++---- docs/local-agent-mcp.md | 85 +++-- docs/self-hosting.md | 154 ++++----- packages/dialect/src/convert.test.ts | 32 ++ packages/dialect/src/dialect.test.ts | 13 + packages/dialect/src/dialect.ts | 4 + packages/dialect/src/index.ts | 1 + packages/dialect/src/nodes/questionnaire.ts | 15 +- packages/editor/src/decision-state.test.ts | 16 +- packages/editor/src/decision-state.ts | 13 +- packages/editor/src/decisions.test.tsx | 64 +++- packages/editor/src/decisions.tsx | 5 +- packages/editor/src/index.ts | 1 + packages/editor/src/widgets/questionnaire.tsx | 15 + packages/protocol/question.d.ts | 16 +- packages/question/src/limits.ts | 3 + .../question/src/react/question-view.test.ts | 37 ++- packages/question/src/react/question-view.tsx | 35 +- 53 files changed, 1519 insertions(+), 883 deletions(-) create mode 100644 apps/server/src/harness/atomic/workspace.ts create mode 100644 apps/server/src/testing/config.ts diff --git a/AGENTS.md b/AGENTS.md index def661c9..ca4c4e73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,10 +113,15 @@ external implementation runs are durable. model-backed research request supplies its GitHub App token and Copilot entitlement. The database stores only a token-free owner reference and durable context. -- **Planner tools are repository-fixed by default.** The hosted runtime has no shell, - checkout, host filesystem, skills, plugins, or arbitrary GitHub access. Local-only - `ATOMIC_PLANNER=full` with a verified checkout deliberately enables the operator's - Atomic tools and resources, including shell and filesystem access. +- **Planner tools are repository-fixed under `copilot-sdk` and `pi`.** Those + Planners have no shell, checkout, host filesystem, skills, plugins, or arbitrary + GitHub access. `HARNESS=atomic` deliberately runs every Planner session, local + or hosted, as a full Atomic session with the operator's Atomic tools and + resources, including shell and filesystem access as the server process's user. + Its working directory is a checkout verified from `invoke_planner` and + remembered per channel in memory, or else an empty per-channel directory. Its + summary and research workers stay isolated. There is no flag for this; the + harness is the choice. - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. - **Persistence should precede publication.** Do not acknowledge or broadcast a @@ -132,9 +137,13 @@ Planner destination without a mention. `instruction()` strips the mention before model input. Recent room messages that did not address the Planner still enter a bounded backscroll for the next turn. -An accepted comment also starts an explicit Planner turn after it commits. +An accepted comment also starts an explicit Planner turn after it commits. An MCP +`invoke_planner` call posts its instruction as the caller's member message and +runs under the channel's existing Planner owner; only a caller with a live +browser login can claim an unowned channel. -The Planner is a custom agent, not a general coding agent. Its turn runs +Under `copilot-sdk` and `pi` the Planner is a custom agent, not a general coding +agent; under `atomic` it is a full Atomic session. Either way its turn runs through `HarnessAgent.stream()` from `@ai-sdk/harness`; the conversation stays active until the returned stream finishes. An interrupted turn is never replayed automatically because it may already have made durable tool changes. @@ -400,7 +409,9 @@ names and the external GitHub MCP contribution. Counts vary with SDK and remote MCP versions. A healthy boundary includes Chopin's document tools (currently plan-named), repository tools, and allowed pull-request tools, and excludes ambient capabilities such as `bash`, filesystem access, URL fetch, host Git, -issues, and unrestricted search. +issues, and unrestricted search. The exception is an `atomic` Planner session, +which deliberately adds Atomic's builtins and coding tools; its summary and +research worker sessions must still show only their own tools. Treat a missing required tool or an unexpected ambient tool as a security or configuration failure even when the overall count looks plausible. diff --git a/README.md b/README.md index 50f436a6..4fd8a1a4 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,11 @@ appear as documents in navigation. https://github.com/user-attachments/assets/72a85be8-685f-4d60-9937-b3855b46cebe The Planner can inspect the selected GitHub repository and its pull requests -through bounded, read-only tools, then co-author the document. It cannot write to -GitHub, edit a checkout, or implement code. A separate coding agent can connect +through bounded, read-only tools, then co-author the document. Under the default +`copilot-sdk` harness, and under `pi`, it cannot write to GitHub, edit a checkout, +or implement code. `HARNESS=atomic` deliberately runs it as a full Atomic session +with shell and filesystem access as the server process's user; see +[Self-hosting](docs/self-hosting.md#choose-and-trust-a-harness). A separate coding agent can connect to Chopin through MCP to create or revise documents and consume an approved implementation graph. @@ -60,8 +63,8 @@ and tool vocabulary remain optimized for planning. - Chopin supports GitHub.com. GitHub Enterprise Server endpoints are not configurable. - The Planner's file, tree, and history tools read the default branch captured - when its session starts; code search is repository-scoped. It never reads a - local checkout or uncommitted changes. + when its session starts; code search is repository-scoped. Except under + `HARNESS=atomic`, it never reads a local checkout or uncommitted changes. - Every browser participant signs in, passes the instance admission policy, and needs repository access through the GitHub App installation. MCP callers also pass instance admission, but use their own bearer token for repository diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index db9ba6f9..1f646f34 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -17,6 +17,7 @@ import { COMPONENTS, DIFF_LANGUAGE, MERMAID_LANGUAGE } from "@chopin/dialect/dialect"; import type { Component } from "@chopin/dialect/dialect"; +import type { PlannerWorkspace } from "../harness/atomic/workspace"; /** Components the agent writes itself. The rest are created for it. */ const AUTHORABLE = ["Callout", "Tabs", "Tab", "Underline"]; @@ -233,18 +234,28 @@ the plan, use the \`detach_question\` operation rather than deleting the block.` export function plannerInstructions( repository: string, bootstrap?: string, - checkout?: string, + workspace?: PlannerWorkspace, ): string { - let access = checkout - ? `The selected repository is ${repository}. Your working directory is the verified local checkout ${checkout}. -You have the operator's Atomic coding tools, workflows and resources, with shell and filesystem access as the local user. -Atomic human input, including workflow questions, goes to Chopin Decisions. Chopin's document tools remain fixed to this document.` - : `Read before you propose. The selected repository is ${repository}. Use + let reading = `Read before you propose. The selected repository is ${repository}. Use \`read_repository_file\`, \`list_repository_tree\`, \`search_repository\` and \`repository_history\` for its code, and \`list_pull_requests\` and -\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository. - -You have no shell, checkout, host filesystem, skills or repository instructions, +\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository.`; + if (!workspace) { + let isolated = `You have no shell, checkout, host filesystem, skills or repository instructions, and cannot change GitHub. Ground the plan in what those reading tools return.`; - return [PROMPT, access, bootstrap].filter(Boolean).join("\n\n"); + return [PROMPT, reading, isolated, bootstrap].filter(Boolean).join("\n\n"); + } + let place = workspace.checkout + ? `Your working directory, ${workspace.cwd}, is a local checkout of ${repository} +verified against its origin. Its branch and working tree may differ from what the +repository tools read.` + : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created +empty for this document. It is not a checkout and holds no repository files, so read +${repository} through the repository tools.`; + let full = `This is a full Atomic session: you have the operator's Atomic coding tools, workflows, +subagents and resources, with shell and filesystem access as the server process's user. +Atomic human input, including workflow questions, goes to Chopin Decisions. Input nobody +answers in time expires with no answer; then proceed on your best judgement and say what +you assumed. Chopin's document tools remain fixed to this document.`; + return [PROMPT, reading, place, full, bootstrap].filter(Boolean).join("\n\n"); } diff --git a/apps/server/src/auth/session.ts b/apps/server/src/auth/session.ts index af801777..3de98e93 100644 --- a/apps/server/src/auth/session.ts +++ b/apps/server/src/auth/session.ts @@ -267,7 +267,7 @@ export class Sessions { : undefined; } - /** A local MCP handoff borrows an existing login, never the caller's bearer. */ + /** An MCP handoff may borrow an existing login to claim ownership, never the caller's bearer. */ async forUser(userId: string): Promise { for (let current of [...this.#sessions.values()].toReversed()) { if (current.session.userId !== userId) continue; diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts index 0e478a44..78fd750e 100644 --- a/apps/server/src/chat/invoke.test.ts +++ b/apps/server/src/chat/invoke.test.ts @@ -1,30 +1,50 @@ -import { expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ActiveOwnerBindings } from "../agent/active-owner"; import { Admission } from "../auth/admission"; import { Sessions } from "../auth/session"; +import { fullPlanner } from "../harness/atomic/full"; +import { removeWorkspaces } from "../harness/atomic/workspace"; +import { openPlannerSession } from "../harness/session"; import * as Plan from "../plan/service"; import { MemoryStorage } from "../storage/memory/adapter"; +import { configured, LOCAL } from "../testing/config"; import * as Chat from "./service"; import type { Server } from "bun"; import type { HostedAuth } from "../auth/routes"; import type { Config } from "../config"; import type { GitHub } from "../github/client"; -import type { SocketData } from "../wire"; +import type { Socket, SocketData } from "../wire"; -async function setup() { +const ATOMIC = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + +let cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0)) await cleanup(); + await removeWorkspaces(); +}); + +function grant(accessToken: string) { + return { + accessToken, + accessExpiresIn: 28_800, + refreshToken: "refresh-token", + refreshExpiresIn: 86_400, + }; +} + +async function setup(config: Config = { agent: true } as Config) { let now = new Date(); let storage = new MemoryStorage(); let user = { id: "U_ana", login: "ana", avatarUrl: "" }; - await storage.users.put({ ...user, now }); + let bob = { id: "U_bob", login: "bob", avatarUrl: "" }; + for (let person of [user, bob]) await storage.users.put({ ...person, now }); let sessions = new Sessions(storage, false); - let login = await sessions.issue(user.id, { - accessToken: "browser-token", - accessExpiresIn: 28_800, - refreshToken: "refresh-token", - refreshExpiresIn: 86_400, - }); + let login = await sessions.issue(user.id, grant("browser-token")); let repository = { id: "R_score", owner: "octo-org", name: "score", defaultBranch: "main" }; let github = { repositoryAccess: async () => ({ @@ -78,28 +98,97 @@ async function setup() { server, auth, repository, - config: { agent: true } as Config, + config, claimantSessionId: login.id, activeOwner: () => owners.resolve(channel.id), persist: () => Plan.persist(plan), }; - return { + let closed = false; + let fixture = { context, events, user, - owners, - storage, + bob, + login, backend, + /** The session that owns the channel's Planner, if any. */ + owner: async () => + (await storage.channels.readAgent(channel.id, new Date()))?.agent?.ownerSessionId, async close() { + if (closed) return; + closed = true; owners.revokeAll(); await Plan.close(plan); }, }; + cleanups.push(fixture.close); + return fixture; +} + +type Seen = { + owner: string; + full: ReturnType; + instructions: string; + prompt: string; +}; + +/** The real session opener, over a harness stub that records what each turn runs with. */ +function observe(context: Chat.Room): Seen[] { + let seen: Seen[] = []; + context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => ({ destroy: async () => {} }) as never, + registerCredential: () => () => {}, + agent: { + createSession: async ({ sessionId }: { sessionId: string }) => ({ + sessionId, + destroy: async () => {}, + }), + stream: async (call: { + session: { sessionId: string }; + prompt: string; + options: { instructions: string }; + }) => { + seen.push({ + owner: owner.ownerSessionId, + full: fullPlanner(call.session.sessionId), + instructions: call.options.instructions, + prompt: call.prompt, + }); + return { fullStream: (async function*() {})() }; + }, + } as never, + }); + return seen; +} + +async function checkout(origin: string): Promise { + let root = await mkdtemp(join(tmpdir(), "chopin-invoke-checkout-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + let git = Bun.spawn(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + } + return realpath(root); +} + +/** A browser member's `@chopin` message, through the ordinary composer path. */ +async function browserTurn(context: Chat.Room, text: string) { + let ws = { data: { handle: "ana", principalId: "U_ana" }, send() {} } as unknown as Socket; + await Chat.send(context, ws, { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "planner", + text, + ts: 0, + } as never); + await context.chat.running; } test("an invoked instruction persists verbatim as a member message before publication and returns before the turn", async () => { - let fixture = await setup(); - let { context, events, user } = fixture; + let { context, events, user } = await setup(); let saved = Promise.withResolvers(); let persist = context.persist; context.persist = async () => { @@ -108,13 +197,12 @@ test("an invoked instruction persists verbatim as a member message before public }; let finished = Promise.withResolvers(); let opened = Promise.withResolvers(); - let seen: Array<{ checkout?: string; prompt: string }> = []; - context.checkout = "/tmp/score"; - context.openPlannerSession = async (_owner, channel) => ({ + let seen: string[] = []; + context.openPlannerSession = async () => ({ ok: true, value: { async stream(prompt) { - seen.push({ checkout: channel.checkout, prompt }); + seen.push(prompt); opened.resolve(); return { fullStream: (async function*() { @@ -140,29 +228,27 @@ test("an invoked instruction persists verbatim as a member message before public text: " @chopin Plan this.\n", }); expect(events.some(event => event.kind === "chat:message")).toBe(true); - expect(seen[0]?.checkout).toBe("/tmp/score"); - expect(seen[0]?.prompt).toBe("@ana: @chopin Plan this.\n"); + expect(seen).toEqual(["@ana: @chopin Plan this.\n"]); finished.resolve(); await context.chat.running; expect(context.chat.busy).toBe(false); } finally { saved.resolve(); finished.resolve(); - await fixture.close(); } }); -test("queued invocations retain their own checkout and durable message exactly once", async () => { +test("queued invocations post their durable message exactly once", async () => { let fixture = await setup(); let { context, user } = fixture; let first = Promise.withResolvers(); let opened = Promise.withResolvers(); - let checkouts: Array = []; - context.openPlannerSession = async (_owner, channel) => ({ + let prompts: string[] = []; + context.openPlannerSession = async () => ({ ok: true, value: { - async stream() { - checkouts.push(channel.checkout); + async stream(prompt) { + prompts.push(prompt); opened.resolve(); return { fullStream: (async function*() { @@ -175,17 +261,16 @@ test("queued invocations retain their own checkout and durable message exactly o }, }); try { - await Chat.invoke({ ...context, checkout: "/tmp/first" }, user, "First"); + await Chat.invoke(context, user, "First"); await opened.promise; - await Chat.invoke({ ...context, checkout: "/tmp/second" }, user, "Second"); + await Chat.invoke(context, user, "Second"); await Chat.invoke(context, user, "Second"); expect(context.chat.waiting.map(item => item.text)).toEqual(["Second", "Second"]); expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); expect(new Set(context.chat.entries.map(entry => entry.id)).size).toBe(3); - expect(checkouts).toEqual(["/tmp/first"]); first.resolve(); await context.chat.running; - expect(checkouts).toEqual(["/tmp/first", "/tmp/second", undefined]); + expect(prompts).toEqual(["@ana: First", "@ana: Second", "@ana: Second"]); expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); expect(context.chat.waiting).toEqual([]); expect(context.chat.busy).toBe(false); @@ -195,47 +280,132 @@ test("queued invocations retain their own checkout and durable message exactly o await Plan.close(restored); } finally { first.resolve(); - await fixture.close(); } }); -test("failed persistence, unavailable Planner, full queue and another owner start no invoked turn", async () => { +test("failed persistence, an unavailable Planner and a full queue start no invoked turn", async () => { + let { context, events, user } = await setup(); + context.config.agent = false; + expect(await Chat.invoke(context, user, "Review")).toBe("planner-unavailable"); + context.config.agent = true; + context.chat.busy = true; + context.chat.waiting = Array.from( + { length: 20 }, + (_, index) => ({ id: String(index), handle: "ana", text: "Waiting" }), + ); + expect(await Chat.invoke(context, user, "Review")).toBe("planner-queue-full"); + context.chat.waiting = []; + context.chat.busy = false; + context.persist = async () => { + throw new Error("storage failed"); + }; + await expect(Chat.invoke(context, user, "Review")).rejects.toThrow("storage failed"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(context.chat.running).toBeUndefined(); +}); + +test("an invocation runs under the channel's existing owner and is attributed to its caller", async () => { let fixture = await setup(); - let { context, user, events } = fixture; - try { - context.config.agent = false; - expect(await Chat.invoke(context, user, "Review")).toBe("planner-unavailable"); - context.config.agent = true; - context.chat.busy = true; - context.chat.waiting = Array.from( - { length: 20 }, - (_, index) => ({ id: String(index), handle: "ana", text: "Waiting" }), - ); - expect(await Chat.invoke(context, user, "Review")).toBe("planner-queue-full"); - context.chat.waiting = []; - context.persist = async () => { - throw new Error("storage failed"); - }; - await expect(Chat.invoke(context, user, "Review")).rejects.toThrow("storage failed"); - expect(context.chat.entries).toEqual([]); - expect(events).toEqual([]); - await fixture.storage.users.put({ id: "U_bob", login: "bob", avatarUrl: "", now: new Date() }); - let login = await context.auth.sessions.issue("U_bob", { - accessToken: "bob-token", - accessExpiresIn: 28_800, - refreshToken: "refresh", - refreshExpiresIn: 86_400, - }); - expect( - await Chat.invoke( - { ...context, claimantSessionId: login.id }, - { id: "U_bob", login: "bob" }, - "Review", - ), - ).toBe("planner-owner-unavailable"); - expect(context.chat.waiting).toEqual([]); - expect(context.chat.running).toBeUndefined(); - } finally { - await fixture.close(); + let { context, user, bob } = fixture; + let owner = await context.auth.sessions.issue(bob.id, grant("bob-token")); + await Chat.resolveOwner(context.auth, context.repository, context.room, owner.id); + let seen = observe(context); + for (let claimant of [undefined, fixture.login.id]) { + expect(await Chat.invoke({ ...context, claimantSessionId: claimant }, user, "Review")) + .toBeUndefined(); + await context.chat.running; + } + expect(seen.map(turn => ({ owner: turn.owner, prompt: turn.prompt }))).toEqual([ + { owner: owner.id, prompt: "@ana: Review" }, + { owner: owner.id, prompt: "@ana: Review" }, + ]); + expect(context.chat.entries.filter(entry => entry.author.kind === "member")).toMatchObject([ + { author: { kind: "member", handle: "ana" }, text: "Review" }, + { author: { kind: "member", handle: "ana" }, text: "Review" }, + ]); + expect(await fixture.owner()).toBe(owner.id); +}); + +test("without an owner the caller's live login claims the Planner, and without either nothing starts", async () => { + let fixture = await setup(); + let { context, events, user } = fixture; + let seen = observe(context); + expect(await Chat.invoke({ ...context, claimantSessionId: undefined }, user, "Review")) + .toBe("planner-owner-unavailable"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(context.chat.running).toBeUndefined(); + expect(await fixture.owner()).toBeUndefined(); + expect(await Chat.invoke(context, user, "Review")).toBeUndefined(); + await context.chat.running; + expect(await fixture.owner()).toBe(fixture.login.id); + expect(seen.map(turn => turn.owner)).toEqual([fixture.login.id]); +}); + +test("the atomic harness verifies a checkout before posting and every later session reuses it", async () => { + let fixture = await setup(configured(ATOMIC)); + let { context, events, user } = fixture; + let seen = observe(context); + let matching = await checkout("git@github.com:octo-org/score.git"); + let other = await checkout("https://github.com/octo-org/other.git"); + for (let path of [other, join(matching, "missing")]) { + expect(await Chat.invoke(context, user, "Review", path)).toBe("checkout-unverified"); + } + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(await fixture.owner()).toBeUndefined(); + + expect(await Chat.invoke(context, user, "Review", matching)).toBeUndefined(); + await context.chat.running; + await browserTurn(context, "@chopin Continue"); + expect(await Chat.invoke(context, user, "Again", other)).toBe("checkout-unverified"); + await browserTurn(context, "@chopin Once more"); + expect(seen).toHaveLength(3); + for (let turn of seen) { + expect(turn.full?.cwd).toBe(matching); + expect(turn.full?.humanInput.questionnaire).toBeFunction(); + expect(turn.instructions).toContain(`${matching}, is a local checkout of octo-org/score`); + } +}); + +test("other harnesses ignore a checkout: it is neither verified, used, nor remembered", async () => { + let { context, user } = await setup(configured()); + let seen = observe(context); + let matching = await checkout("workgit:octo-org/score.git"); + for (let path of ["/does/not/exist", matching]) { + expect(await Chat.invoke(context, user, "Review", path)).toBeUndefined(); + await context.chat.running; + } + expect(seen.map(turn => turn.full)).toEqual([undefined, undefined]); + for (let turn of seen) expect(turn.instructions).toContain("You have no shell"); + context.config = configured(ATOMIC); + await browserTurn(context, "@chopin Continue"); + let atomic = seen.at(-1)!.full!; + expect(atomic.cwd).not.toBe(matching); + expect(await readdir(atomic.cwd)).toEqual([]); +}); + +test("HARNESS=atomic runs every Planner session full in hosted and local configuration", async () => { + for ( + let [overrides, full] of [ + [ATOMIC, true], + [{ ...ATOMIC, ...LOCAL }, true], + [{}, false], + [{ HARNESS: "pi", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model", ...LOCAL }, false], + ] as const + ) { + let { context, user } = await setup(configured(overrides)); + let seen = observe(context); + expect(await Chat.invoke(context, user, "Review")).toBeUndefined(); + await context.chat.running; + await browserTurn(context, "@chopin Continue"); + expect(seen).toHaveLength(2); + for (let turn of seen) { + let harness = context.config.harness; + expect({ harness, full: !!turn.full }).toEqual({ harness, full }); + expect(!!turn.full?.humanInput).toBe(full); + expect(turn.instructions.includes("This is a full Atomic session")).toBe(full); + } } }); diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 9718953b..c88749af 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -22,6 +22,8 @@ import { ulid } from "@chopin/dialect"; import { PLANNER_TOOL_NAMES } from "../harness/tool-names"; import type { PlannerSession } from "../harness/session"; +import { verifiedCheckout } from "../harness/atomic/checkout"; +import { rememberCheckout } from "../harness/atomic/workspace"; import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { DocumentRoom, ResearchWorkspaceRequest } from "../agent/tools"; @@ -83,7 +85,7 @@ type Waiting = Wire.Waiting & { /** Verified member identity, retained only for a queued composer message. */ userId?: string; posted?: boolean; - checkout?: string; + /** MCP caller whose instruction this is; it never claims ownership without their login. */ invokedBy?: string; }; @@ -92,7 +94,7 @@ export type ActiveMemberRequest = { userId: string; handle: string; text: string; - claimantSessionId: string; + claimantSessionId: string | undefined; turnId: string; lifecycle: number; }; @@ -404,12 +406,16 @@ export type Room = { room: string; config: Config; auth: HostedAuth; - claimantSessionId: string; + /** + * Login session that may claim Planner ownership for this room's turns. + * Absent for an MCP caller without a live browser login: their instruction + * runs only under an owner the channel already has. + */ + claimantSessionId: string | undefined; repository: HostedRepository; activeOwner?: () => Promise; persist: () => Promise; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; - checkout?: string; invokedBy?: string; ownerAvailable?: () => Promise; jobs?: JobService; @@ -444,25 +450,37 @@ export function send(context: Room, ws: Socket, msg: Request): Promis return completed; } -/** Accept a local MCP instruction without waiting for its Planner turn. */ +/** + * Accept an MCP instruction without waiting for its Planner turn. + * + * The caller is attributed on the posted message, and the turn runs under the + * channel's Planner owner. A checkout matters only to the atomic harness, which + * verifies it before anything is posted and remembers it for the channel. + */ export async function invoke( context: Room, user: { id: string; login: string }, text: string, -): Promise<"planner-unavailable" | "planner-owner-unavailable" | "planner-queue-full" | undefined> { + checkout?: string, +): Promise< + | "planner-unavailable" + | "planner-owner-unavailable" + | "planner-queue-full" + | "checkout-unverified" + | undefined +> { let { chat, server, room } = context; if (chat.pendingSends >= MAX_PENDING_SENDS) return "planner-queue-full"; chat.pendingSends++; let post = async () => { if (!context.config.agent || chat.closed) return "planner-unavailable" as const; + let verified: string | undefined; + if (checkout !== undefined && context.config.harness === "atomic") { + verified = await verifiedCheckout(context.repository, checkout); + if (!verified) return "checkout-unverified" as const; + } try { - let { owner } = await resolveOwner( - context.auth, - context.repository, - room, - context.claimantSessionId, - ); - if (owner.user.id !== user.id) return "planner-owner-unavailable" as const; + await resolveOwner(context.auth, context.repository, room, context.claimantSessionId); } catch { return "planner-owner-unavailable" as const; } @@ -482,6 +500,7 @@ export async function invoke( throw error; } if (chat.closed) return "planner-unavailable" as const; + if (verified) rememberCheckout(room, verified); announce(server, room, entry); if (chat.busy) { chat.waiting.push({ @@ -492,7 +511,6 @@ export async function invoke( posted: true, sessionId: context.claimantSessionId, userId: user.id, - checkout: context.checkout, invokedBy: user.id, }); queued(chat, server, room); @@ -773,7 +791,7 @@ function currentMemberRequest(chat: Chat): ActiveMemberRequest | undefined { let active = chat.activeRequest; if ( !active || chat.closed || !chat.busy || chat.lifecycle !== active.lifecycle - || chat.turn?.id !== active.turnId || !active.userId || !active.claimantSessionId + || chat.turn?.id !== active.turnId || !active.userId ) return undefined; let entry = chat.entries.find(value => value.id === active.entryId); if ( @@ -916,7 +934,7 @@ export function consumeBootstrapBackscroll(chat: Chat): void { async function repositorySession( context: Room, - claimantSessionId: string, + claimantSessionId: string | undefined, currentEntryId?: string, currentReferences: Wire.Reference[] = [], ): Promise<{ session: PlannerSession; binding: ActiveOwnerBinding }> { @@ -926,9 +944,6 @@ async function repositorySession( context.room, claimantSessionId, ); - if (context.invokedBy && owner.user.id !== context.invokedBy) { - throw new Error("The local Planner owner changed. Invoke the Planner again after signing in."); - } if (context.ownerAvailable) void context.ownerAvailable().catch(() => {}); let { chat, auth } = context; let lifecycle = chat.lifecycle; @@ -972,15 +987,14 @@ async function repositorySession( let result = await open(binding, { room: documentRoom(context), repository, - instructions: checkout => + instructions: workspace => plannerInstructions( `${repository.owner}/${repository.name}`, bootstrap, - checkout, + workspace, ), model: context.config.model, - atomicPlanner: context.config.atomicPlanner, - checkout: context.checkout, + harness: context.config.harness, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); opened = result.value; @@ -1011,19 +1025,23 @@ async function repositorySession( } } +/** + * The channel's Planner owner, claimed for the claimant when it has none. + * Without a claimant only an owner the channel already has will do. + */ export async function resolveOwner( auth: HostedAuth, repository: HostedRepository, channelId: string, - claimantSessionId: string, + claimantSessionId: string | undefined, ) { - let ownership = await auth.storage.channels.claimAgentOwner( - channelId, - claimantSessionId, - new Date(), - ); - let ownerSessionId = ownership.ownerSessionId; - if (!ownerSessionId) throw new Error("This channel's Copilot owner is unavailable."); + let ownership = claimantSessionId + ? await auth.storage.channels.claimAgentOwner(channelId, claimantSessionId, new Date()) + : (await auth.storage.channels.readAgent(channelId, new Date()))?.agent; + let ownerSessionId = ownership?.ownerSessionId; + if (!ownership || !ownerSessionId) { + throw new Error("This channel's Copilot owner is unavailable."); + } let owner = await auth.sessions.resolve(ownerSessionId); if (!owner) { throw new Error("The Copilot owner must sign in again or reset this channel's agent."); @@ -1064,7 +1082,7 @@ async function run( handle: string, text: string, thread: string | undefined, - claimantSessionId: string, + claimantSessionId: string | undefined, reserved = false, member?: MemberRequest, references: Wire.Reference[] = [], @@ -1175,11 +1193,11 @@ async function run( let entry = next.message ? chat.entries.find(item => item.id === next.id) : undefined; if (entry && !next.posted) announce(server, room, entry); await run( - { ...context, checkout: next.checkout, invokedBy: next.invokedBy }, + { ...context, invokedBy: next.invokedBy }, next.handle, next.text, next.thread, - next.sessionId ?? context.claimantSessionId, + next.invokedBy ? next.sessionId : next.sessionId ?? context.claimantSessionId, false, next.message && next.userId ? { entryId: next.id, userId: next.userId } : undefined, next.references, @@ -1197,7 +1215,7 @@ function startRun( handle: string, text: string, thread: string | undefined, - claimantSessionId: string, + claimantSessionId: string | undefined, reserved = false, member?: MemberRequest, references?: Wire.Reference[], diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 0faa5e04..614aebb6 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,50 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { delimiter } from "node:path"; -import { describe as description, load } from "./config"; - -const REQUIRED = { - STORAGE_DRIVER: "postgres", - DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", - APP_ORIGIN: "https://chopin.example", - GITHUB_APP_SLUG: "chopin-test", - GITHUB_APP_CLIENT_ID: "client-id", - GITHUB_APP_CLIENT_SECRET: "client-secret", - SESSION_ENCRYPTION_KEY: "11".repeat(32), -}; - -function configured(overrides: Record = {}) { - let env: Record = { - ...REQUIRED, - AGENT: undefined, - BACKGROUND_JOBS: undefined, - WEB_RESEARCH: undefined, - HARNESS: undefined, - HARNESS_AUTH: undefined, - AUTH_MODE: undefined, - ATOMIC_PLANNER: undefined, - ATOMIC_PLANNER_CHECKOUTS: undefined, - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: undefined, - SERVER_HOST: undefined, - PORT: undefined, - MODEL: undefined, - CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, - GITHUB_ALLOWED_USERS: undefined, - GITHUB_ALLOWED_ORGANIZATIONS: undefined, - ...overrides, - }; - let previous = { ...process.env }; - for (let [key, value] of Object.entries(env)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - try { - return load(); - } finally { - for (let key of Object.keys(env)) delete process.env[key]; - Object.assign(process.env, previous); - } -} +import { describe as description } from "./config"; +import { configured, LOCAL, REQUIRED } from "./testing/config"; describe("configuration", () => { it("loads the mandatory GitHub and PostgreSQL services without printing secrets", () => { @@ -130,87 +87,25 @@ describe("configuration", () => { }); }); - it("allows full Atomic Planner only in local atomic mode, with either auth mode", () => { - let full = { - ATOMIC_PLANNER: "full", - AUTH_MODE: "local", - HARNESS: "atomic", - MODEL: "stub/model", - APP_ORIGIN: "http://localhost:8790", - PORT: "8790", - }; - for (let auth of ["auto", "ai-gateway"]) { - expect(configured({ ...full, HARNESS_AUTH: auth }).atomicPlanner).toEqual({ - checkouts: [], - }); - } - expect(configured().atomicPlanner).toBeUndefined(); - for (let mode of ["isolated", "FULL", " "]) { - expect(() => configured({ ...full, ATOMIC_PLANNER: mode })).toThrow("ATOMIC_PLANNER"); - } - for (let auth of [undefined, "hosted"]) { - expect(() => configured({ ...full, AUTH_MODE: auth })).toThrow("AUTH_MODE=local"); - } - for (let harness of [undefined, "pi", "copilot-sdk"]) { - expect(() => configured({ ...full, HARNESS: harness })).toThrow("HARNESS=atomic"); - } - }); - - it("validates checkout paths and an optional per-request input timeout", () => { - let full = { - ATOMIC_PLANNER: "full", - AUTH_MODE: "local", - HARNESS: "atomic", - MODEL: "stub/model", - APP_ORIGIN: "http://localhost:8790", - PORT: "8790", - }; - expect( - configured({ - ...full, - ATOMIC_PLANNER_CHECKOUTS: ["/tmp/one", "/tmp/two", "/tmp/one"].join(delimiter), - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "1234", - }).atomicPlanner, - ).toEqual({ checkouts: ["/tmp/one", "/tmp/two", "/tmp/one"], inputTimeoutMs: 1234 }); - for (let paths of ["relative", ["/tmp/one", "relative"].join(delimiter)]) { - expect(() => configured({ ...full, ATOMIC_PLANNER_CHECKOUTS: paths })).toThrow("absolute"); - } - for (let timeout of ["0", "-1", "1.5", "oops", "10ms", "2147483648"]) { - expect(() => configured({ ...full, ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout })).toThrow( - "ATOMIC_PLANNER_INPUT_TIMEOUT_MS", + it("chooses a full Atomic Planner by harness alone, in hosted and local configuration", () => { + let atomic = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + for (let mode of [{}, LOCAL]) { + let config = configured({ ...atomic, ...mode }); + expect(config.harness).toBe("atomic"); + expect(description(config)).toContain( + "Planner: full Atomic session (shell and filesystem access as this process's user)", ); } - }); - - it("ignores Atomic Planner settings outside full mode and treats empty values as unset", () => { - for (let planner of [undefined, ""]) { - for (let [checkouts, timeout] of [["", ""], ["relative", "oops"]]) { - expect( - configured({ - ATOMIC_PLANNER: planner, - ATOMIC_PLANNER_CHECKOUTS: checkouts, - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout, - }).atomicPlanner, - ).toBeUndefined(); + for ( + let isolated of [ + {}, + { HARNESS: "pi", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }, + ] + ) { + for (let mode of [{}, LOCAL]) { + expect(description(configured({ ...isolated, ...mode }))).not.toContain("full Atomic"); } } - let full = { - ATOMIC_PLANNER: "full", - AUTH_MODE: "local", - HARNESS: "atomic", - MODEL: "stub/model", - APP_ORIGIN: "http://localhost:8790", - PORT: "8790", - }; - for (let checkouts of [undefined, ""]) { - expect( - configured({ - ...full, - ATOMIC_PLANNER_CHECKOUTS: checkouts, - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "", - }).atomicPlanner, - ).toEqual({ checkouts: [] }); - } }); it("requires a valid PostgreSQL URL", () => { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b2a645ae..4e3ef036 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,7 +6,6 @@ * than a puzzling behaviour three screens later. */ -import { delimiter, isAbsolute } from "node:path"; import { loadAuth } from "./auth/config"; import type { AuthConfig } from "./auth/config"; @@ -19,7 +18,6 @@ export type Config = { model: string; harness: string; harnessAuth: string | undefined; - atomicPlanner?: { checkouts: string[]; inputTimeoutMs?: number }; /** * Whether to run the agent at all. * @@ -61,40 +59,12 @@ function model(harness: string): string { } return DEFAULT_MODEL; } -export function harnessSelection(): Pick< - Config, - "host" | "harness" | "harnessAuth" | "atomicPlanner" -> { - let full = process.env.ATOMIC_PLANNER; - if (full && full !== "full") throw new Error("ATOMIC_PLANNER must be full or unset"); - if (full && (process.env.AUTH_MODE !== "local" || process.env.HARNESS !== "atomic")) { - throw new Error("ATOMIC_PLANNER=full requires AUTH_MODE=local and HARNESS=atomic"); - } - let selection = { +export function harnessSelection(): Pick { + return { host: process.env.SERVER_HOST || "127.0.0.1", harness: process.env.HARNESS || "copilot-sdk", harnessAuth: process.env.HARNESS_AUTH || undefined, }; - if (!full) return selection; - let checkouts = (process.env.ATOMIC_PLANNER_CHECKOUTS || undefined)?.split(delimiter) ?? []; - if (checkouts.some(path => !isAbsolute(path))) { - throw new Error( - "ATOMIC_PLANNER_CHECKOUTS must contain absolute paths separated by the platform path delimiter", - ); - } - let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS || undefined; - let inputTimeoutMs = rawTimeout === undefined ? undefined : Number(rawTimeout); - if ( - inputTimeoutMs !== undefined - && (!Number.isSafeInteger(inputTimeoutMs) || inputTimeoutMs < 1 - || inputTimeoutMs > 2_147_483_647) - ) { - throw new Error("ATOMIC_PLANNER_INPUT_TIMEOUT_MS must be an integer between 1 and 2147483647"); - } - return { - ...selection, - atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, - }; } function port(): number { @@ -163,7 +133,9 @@ export function describe(config: Config): string { config.devClient ? `client: vite (${config.devClient})` : "client: built", config.agent ? `agent: ${config.model} (on demand)` : "agent: off", `harness: ${config.harness}`, - ...(config.atomicPlanner ? ["Atomic Planner: full (local shell and filesystem access)"] : []), + ...(config.harness === "atomic" + ? ["Planner: full Atomic session (shell and filesystem access as this process's user)"] + : []), config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", admission, diff --git a/apps/server/src/harness/atomic.contract.test.ts b/apps/server/src/harness/atomic.contract.test.ts index ea7514ed..1e3509d4 100644 --- a/apps/server/src/harness/atomic.contract.test.ts +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -66,6 +66,10 @@ function createStubAtomic(overrides: Partial = {}) { harnessContract("atomic", createStubAtomic, createJustBashNetworkSandboxSession); +/** + * Sessions here are never registered as Planner sessions, so each is the + * isolated session the summary and research workers run in. + */ async function run(prompt: string, options: { structured?: boolean; instructions?: string; @@ -109,7 +113,7 @@ async function run(prompt: string, options: { } } -describe("atomic turns", () => { +describe("atomic worker turns", () => { it("answers a plain text turn with exactly the neutral system prompt and no tools", async () => { stub.requests.length = 0; let result = await run("plain"); @@ -351,7 +355,7 @@ describe("atomic HarnessV1 lifecycle", () => { }); }); -describe("atomic structured-output result tool", () => { +describe("atomic worker structured-output result tool", () => { it("ends a structured turn on the terminating tool without a follow-up model request", async () => { stub.requests.length = 0; let result = await run("output", { structured: true }); @@ -387,7 +391,7 @@ describe("atomic structured-output result tool", () => { }); }); -describe("atomic host isolation and model resolution", () => { +describe("atomic worker host isolation and model resolution", () => { async function snapshot(root: string): Promise> { let files = new Map(); for (let entry of await readdir(root, { recursive: true })) { diff --git a/apps/server/src/harness/atomic/adapter.test.ts b/apps/server/src/harness/atomic/adapter.test.ts index 0e4fd3a5..8e35994e 100644 --- a/apps/server/src/harness/atomic/adapter.test.ts +++ b/apps/server/src/harness/atomic/adapter.test.ts @@ -37,7 +37,8 @@ function fakeExtensionApi(policy: TurnPolicy) { get active() { return active; }, - start: () => handlers.get("before_agent_start")!({ type: "before_agent_start" }), + start: (systemPrompt?: string) => + handlers.get("before_agent_start")!({ type: "before_agent_start", systemPrompt }), call: (toolName: string) => handlers.get("tool_call")!({ type: "tool_call", toolName }), }; } @@ -51,7 +52,7 @@ test("registers one result tool that terminates the turn and records its argumen expect(policy.result).toEqual({ value: { answer: "yes" } }); }); -test("applies the turn's tools and exact system prompt when the agent starts", () => { +test("applies a worker turn's tools and exact system prompt when the agent starts", () => { let policy: TurnPolicy = { hostNames: ["first_host"], structured: true, systemPrompt: "Plan." }; let atomic = fakeExtensionApi(policy); expect(atomic.start()).toEqual({ systemPrompt: "Plan." }); @@ -63,7 +64,7 @@ test("applies the turn's tools and exact system prompt when the agent starts", ( expect(atomic.active).toEqual(["first_host"]); }); -test("blocks the result tool off structured turns and anything outside the turn", () => { +test("blocks, for a worker, the result tool off structured turns and anything outside the turn", () => { let policy: TurnPolicy = { hostNames: ["first_host"], structured: false, systemPrompt: "S." }; let atomic = fakeExtensionApi(policy); expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toMatchObject({ block: true }); @@ -73,6 +74,23 @@ test("blocks the result tool off structured turns and anything outside the turn" expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toBeUndefined(); }); +test("a Planner turn keeps Atomic's tools and prompt but still refuses the result tool on plain turns", () => { + let policy: TurnPolicy = { + full: true, + hostNames: ["first_host"], + structured: false, + systemPrompt: "Plan.", + }; + let atomic = fakeExtensionApi(policy); + expect(atomic.start("Atomic.")).toEqual({ systemPrompt: "Atomic.\n\nPlan." }); + expect(atomic.active).toEqual([]); + expect(atomic.call("bash")).toBeUndefined(); + expect(atomic.call("first_host")).toBeUndefined(); + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toMatchObject({ block: true }); + policy.structured = true; + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toBeUndefined(); +}); + test("builds the system prompt from instructions and the structured response format", () => { expect(turnSystemPrompt(undefined, undefined)).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); expect(turnSystemPrompt("", { type: "text" })).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); @@ -118,7 +136,7 @@ test("resolves provider/model exactly and never substitutes a default", () => { .toThrow("HARNESS_AUTH ai-gateway allows only vercel-ai-gateway models, not anthropic/claude"); }); -test("reports every way a built session exceeds the turn", () => { +test("reports every way a built worker session exceeds the turn", () => { function session(options: { tools?: string[]; agentsFiles?: number; diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index 9ea63be7..3df1c50c 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -1,9 +1,11 @@ /** * Chopin's Atomic `HarnessV1` adapter. * - * Sessions are isolated by default: only Chopin host tools, no host resources, - * and a checked per-turn tool boundary. A registered local full Planner uses - * its verified checkout and Atomic resources instead, with Chopin as HostInput. + * A Planner session that Chopin registers by session ID runs as a full Atomic + * session: Atomic builtins and coding tools, the operator's Atomic resources, a + * channel working directory, and Chopin as HostInput. Every other session (the + * summary and research workers) stays isolated: only Chopin host tools, no host + * resources, and a checked per-turn tool boundary. */ import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; @@ -90,7 +92,6 @@ export type AtomicSettings = { model?: string; /** Providers registered by code rather than discovered from the host. */ providers?: Record; - fullPlanner?: boolean; }; export class ToolBoundaryError extends Error { @@ -319,7 +320,7 @@ export function createAtomicAdapter( throw unsupported("resuming an in-memory session"); } let directory: string | undefined; - let full = settings.fullPlanner ? fullPlanner(startOptions.sessionId) : undefined; + let full = fullPlanner(startOptions.sessionId); let sessionManager: SessionManager | undefined; let settingsManager = SettingsManager.inMemory(SETTINGS); let policy: TurnPolicy = { diff --git a/apps/server/src/harness/atomic/checkout.test.ts b/apps/server/src/harness/atomic/checkout.test.ts index 8c90c900..22b788f2 100644 --- a/apps/server/src/harness/atomic/checkout.test.ts +++ b/apps/server/src/harness/atomic/checkout.test.ts @@ -18,8 +18,7 @@ async function checkout(name: string, origin: string) { return path; } -test("verifies origin owner/name for URL, SSH and alias checkouts, preserving candidate priority", async () => { - let paths = []; +test("verifies origin owner/name for URL, SSH and alias checkouts, and nothing else", async () => { for ( let [index, origin] of [ "https://github.com/octo-org/score.git", @@ -29,12 +28,10 @@ test("verifies origin owner/name for URL, SSH and alias checkouts, preserving ca ].entries() ) { let path = await checkout(String(index), origin); - paths.push(path); - expect(await verifiedCheckout(repository, [path])).toBe(await realpath(path)); + expect(await verifiedCheckout(repository, path)).toBe(await realpath(path)); } - expect(await verifiedCheckout(repository, paths, paths[3])).toBe(paths[3]); let mismatch = await checkout("mismatch", "https://github.com/other/score.git"); - expect(await verifiedCheckout(repository, [mismatch, paths[1]!], mismatch)).toBe(paths[1]); - expect(await verifiedCheckout(repository, [mismatch, root, join(root, "missing")])) - .toBeUndefined(); + for (let path of [mismatch, root, join(root, "missing")]) { + expect(await verifiedCheckout(repository, path)).toBeUndefined(); + } }); diff --git a/apps/server/src/harness/atomic/checkout.ts b/apps/server/src/harness/atomic/checkout.ts index f8589a63..0a6f710b 100644 --- a/apps/server/src/harness/atomic/checkout.ts +++ b/apps/server/src/harness/atomic/checkout.ts @@ -3,35 +3,30 @@ import { realpath } from "node:fs/promises"; /** Origin host names may be SSH aliases; the repository coordinates must match. */ export async function verifiedCheckout( repository: { owner: string; name: string }, - checkouts: string[], - preferred?: string, + candidate: string, ): Promise { - for (let candidate of preferred === undefined ? checkouts : [preferred, ...checkouts]) { - try { - let cwd = await realpath(candidate); - let git = async (...args: string[]) => { - let process = Bun.spawn(["git", "-C", cwd, ...args], { - stdout: "pipe", - stderr: "pipe", - }); - let output = await new Response(process.stdout).text(); - if (await process.exited !== 0) throw new Error("not a checkout"); - return output.trim(); - }; - if (await git("rev-parse", "--is-inside-work-tree") !== "true") continue; - let origin = await git("remote", "get-url", "origin"); - let path = origin.includes("://") - ? new URL(origin).pathname.replace(/^\//, "") - : /^[^/:]+:(.+)$/.exec(origin)?.[1]; - if ( - path?.replace(/\.git$/, "").toLowerCase() - === `${repository.owner}/${repository.name}`.toLowerCase() - ) { - return cwd; - } - } catch { - // A missing or unrelated checkout retains the isolated Planner boundary. - } + try { + let cwd = await realpath(candidate); + let git = async (...args: string[]) => { + let process = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + let output = await new Response(process.stdout).text(); + if (await process.exited !== 0) throw new Error("not a checkout"); + return output.trim(); + }; + if (await git("rev-parse", "--is-inside-work-tree") !== "true") return undefined; + let origin = await git("remote", "get-url", "origin"); + let path = origin.includes("://") + ? new URL(origin).pathname.replace(/^\//, "") + : /^[^/:]+:(.+)$/.exec(origin)?.[1]; + return path?.replace(/\.git$/, "").toLowerCase() + === `${repository.owner}/${repository.name}`.toLowerCase() + ? cwd + : undefined; + } catch { + // A missing path or an unrelated checkout is simply not verified. + return undefined; } - return undefined; } diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 7dd8aa51..ace21b68 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -61,15 +61,24 @@ let stub = startStubModelServer((prompt, prior) => ); afterAll(stub.stop); -async function run(full: boolean, registered: boolean, prompt = "plain", host?: HostInput) { +async function run( + registered: boolean, + prompt = "plain", + { host, checkout = true, worker = false }: { + host?: HostInput; + /** False runs the Planner in an empty directory, as a channel without a checkout does. */ + checkout?: boolean; + /** Also run an unregistered worker session on the same harness while the Planner is open. */ + worker?: boolean; + } = {}, +) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); let agentDir = join(root, "agent"); - let cwd = join(root, "checkout"); + let cwd = join(root, checkout ? "checkout" : "empty"); let previous = process.env.ATOMIC_CODING_AGENT_DIR; let received: QuestionParams[] = []; let harness = createAtomicAdapter({ auth: "ai-gateway", - fullPlanner: full, model: "stub/model", providers: { stub: { @@ -109,8 +118,13 @@ async function run(full: boolean, registered: boolean, prompt = "plain", host?: join(agentDir, "extensions", "marker.ts"), `export default api => api.registerTool({name: "operator_tool", label: "Operator", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"marker"}], details:{}}; }});`, ); - let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { stdout: "pipe", stderr: "pipe" }); - expect(await git.exited).toBe(0); + if (checkout) { + let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { + stdout: "pipe", + stderr: "pipe", + }); + expect(await git.exited).toBe(0); + } process.env.ATOMIC_CODING_AGENT_DIR = agentDir; let humanInput: HostInput = host ?? { confirm: async () => false, @@ -151,7 +165,17 @@ async function run(full: boolean, registered: boolean, prompt = "plain", host?: let result = await agent.stream({ session, prompt }); await result.consumeStream(); await result.text; - return { cwd, received, requests: [...stub.requests] }; + let requests = [...stub.requests]; + if (!worker) return { cwd, received, requests, workerRequests: [] }; + let workerSession = await agent.createSession({ sandboxSession: sandbox }); + try { + stub.requests.length = 0; + let output = await agent.stream({ session: workerSession, prompt: "plain" }); + await output.consumeStream(); + return { cwd, received, requests, workerRequests: [...stub.requests] }; + } finally { + await workerSession.destroy(); + } } finally { await session.destroy(); } @@ -165,24 +189,24 @@ async function run(full: boolean, registered: boolean, prompt = "plain", host?: } } -test("verified full Planner sessions load operator resources and expose Atomic tools beside host tools", async () => { - let result = await run(true, true); +const FULL_TOOLS = [ + "read", + "bash", + "edit", + "write", + "ask_user_question", + "workflow", + "subagent", + "intercom", + "web_search", + "operator_tool", + "host_tool", +]; + +test("registered Planner sessions load operator resources and expose Atomic tools beside host tools", async () => { + let result = await run(true); let request = result.requests[0]!; - for ( - let name of [ - "read", - "bash", - "edit", - "write", - "ask_user_question", - "workflow", - "subagent", - "intercom", - "web_search", - "operator_tool", - "host_tool", - ] - ) expect(request.toolNames).toContain(name); + for (let name of FULL_TOOLS) expect(request.toolNames).toContain(name); expect(request.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME); for ( let marker of [ @@ -195,21 +219,28 @@ test("verified full Planner sessions load operator resources and expose Atomic t expect(request.system).not.toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); }); -test("full sessions use the checkout cwd and route ask_user_question through HostInput", async () => { - let cwd = await run(true, true, "cwd"); +test("Planner sessions use their checkout cwd and route ask_user_question through HostInput", async () => { + let cwd = await run(true, "cwd"); expect(cwd.requests.at(-1)!.toolResults.join("\n")).toContain(cwd.cwd); - let question = await run(true, true, "question"); + let question = await run(true, "question"); expect(question.received).toEqual([params]); expect(question.requests.at(-1)!.toolResults.join("\n")).toContain("Second"); - let prompt = await run(true, true, "/marker"); + let prompt = await run(true, "/marker"); expect(prompt.requests[0]!.prompt).toBe("OPERATOR-PROMPT-MARKER"); }); +test("a Planner session without a checkout is just as full in its empty working directory", async () => { + let result = await run(true, "cwd", { checkout: false }); + for (let name of FULL_TOOLS) expect(result.requests[0]!.toolNames).toContain(name); + expect(result.requests[0]!.system).toContain(result.cwd); + expect(result.requests.at(-1)!.toolResults.join("\n")).toContain(result.cwd); +}); + test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { let room = await hostInputRoom(); try { for (let prompt of ["multiple", "preview"]) { - let turn = run(true, true, prompt, room.input); + let turn = run(true, prompt, { host: room.input }); let [card] = await room.cards(1); await room.answer(card!.id, ` typed ${prompt}\n`); let output = (await turn).requests.at(-1)!.toolResults.join("\n"); @@ -221,10 +252,13 @@ test("free-text Decisions answers to multi-select and preview questions reach th } }); -test("workers without a verified registration and default-mode sessions stay isolated", async () => { - for (let [full, registered] of [[true, false], [false, true]]) { - let result = await run(full!, registered!); - expect(result.requests[0]!.toolNames).toEqual(["host_tool"]); - expect(result.requests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); - } +test("worker sessions stay isolated, even beside a full Planner session on the same harness", async () => { + let alone = await run(false); + expect(alone.requests[0]!.toolNames).toEqual(["host_tool"]); + expect(alone.requests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); + let beside = await run(true, "plain", { worker: true }); + expect(beside.requests[0]!.toolNames).toContain("bash"); + expect(beside.workerRequests).toHaveLength(1); + expect(beside.workerRequests[0]!.toolNames).toEqual(["host_tool"]); + expect(beside.workerRequests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); }); diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts index 458a84cb..fc79c5a5 100644 --- a/apps/server/src/harness/atomic/full.ts +++ b/apps/server/src/harness/atomic/full.ts @@ -3,7 +3,7 @@ import type { HostInput } from "@bastani/atomic"; export type FullPlanner = { cwd: string; humanInput: HostInput }; let planners = new Map(); -/** Only a verified Planner session is registered; background workers are not. */ +/** Every Atomic Planner session is registered; background workers never are. */ export function registerFullPlanner(sessionId: string, planner: FullPlanner): () => void { if (planners.has(sessionId)) throw new Error("Atomic Planner session is already registered"); planners.set(sessionId, planner); diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index e7f3926c..226d8dd5 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test } from "bun:test"; import { $nodesOfType } from "lexical"; -import { limits, QuestionnaireNode } from "@chopin/dialect"; +import { limits, QuestionnaireNode, unanswered } from "@chopin/dialect"; +import { limits as questionLimits } from "@chopin/question"; import * as Questions from "../../questions/service"; import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; @@ -14,8 +15,8 @@ afterEach(async () => { for (let cleanup of cleanups.splice(0)) await cleanup(); }); -async function fixture(timeoutMs?: number) { - let f = await hostInputRoom(timeoutMs); +async function fixture(expiresInMs?: number) { + let f = await hostInputRoom(expiresInMs); cleanups.push(f.close); return f; } @@ -223,25 +224,90 @@ test("abort withdraws only its request's open cards, persists cancellation, and expect(saved.source).not.toContain(cards[1]!.id); }); -test("timeout withdraws unanswered dialogs and returns no answer for every HostInput method", async () => { +test("expiry keeps every unanswered card in Decisions, marked expired, and answers no HostInput method", async () => { let f = await fixture(30); for ( - let invoke of [ - () => f.input.questionnaire(params, f.options), - () => f.input.confirm("Confirm", "Proceed?", f.options), - () => f.input.select("Select", ["A", "B"], f.options), - () => f.input.input("Input", undefined, f.options), - () => f.input.editor("Editor", undefined, f.options), - ] + let [invoke, expected] of [ + [() => f.input.questionnaire(params, f.options), { answers: [], cancelled: true }], + [() => f.input.confirm("Confirm", "Proceed?", f.options), false], + [() => f.input.select("Select", ["A", "B"], f.options), undefined], + [() => f.input.input("Input", undefined, f.options), undefined], + [() => f.input.editor("Editor", undefined, f.options), undefined], + ] as Array<[() => Promise, unknown]> ) { - let result = await invoke(); - expect( - result === undefined || result === false - || (typeof result === "object" && result.cancelled && result.answers.length === 0), - ).toBe(true); + expect(await invoke()).toEqual(expected); expect(Store.outstanding(f.plan.questions)).toHaveLength(0); } - expect([...f.plan.records.values()].every(record => record.status === "cancelled")).toBe(true); + let records = [...f.plan.records.values()]; + expect(records).toHaveLength(7); + for (let record of records) { + expect(record).toMatchObject({ status: "expired", resolver: "chopin" }); + expect(record.at).toBeNumber(); + expect(f.frames).toContainEqual( + expect.objectContaining({ + kind: "question:resolved", + id: record.id, + status: "expired", + resolver: "chopin", + }), + ); + } + let saved = await Plan.readStored((await f.storage.collaboration.load(f.room.id, new Date()))!); + let cards = await documentCards(saved.source); + expect(cards.map(card => card.id)).toEqual(records.map(record => record.id)); + for (let card of cards) { + expect(card.status).toBe("expired"); + expect(card.at).toBeString(); + expect(card.by).toBeUndefined(); + expect(unanswered(card)).toEqual([]); + } + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + ts: 0, + rid: "late", + id: records[0]!.id, + revision: 0, + }); + expect(f.frames.findLast(frame => frame.rid === "late")).toMatchObject({ + ok: false, + reason: "resolved", + status: "expired", + resolver: "chopin", + }); + expect(f.plan.records.get(records[0]!.id)?.status).toBe("expired"); +}); + +test("an abort still withdraws its cards rather than expiring them", async () => { + let f = await fixture(60_000); + let response = f.input.confirm("Confirm", "Proceed?", f.options); + let [card] = await f.cards(1); + f.controller.abort(); + expect(await response).toBe(false); + expect(f.plan.records.get(card!.id)).toMatchObject({ status: "cancelled", resolver: "chopin" }); + expect(Plan.source(f.plan)).not.toContain(card!.id); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toMatchObject([ + { kind: "question:resolved", id: card!.id, status: "cancelled", resolver: "chopin" }, + ]); +}); + +test("host input waits the shared 30-minute limit before expiring by default", async () => { + let f = await fixture(); + let delays: number[] = []; + let original = globalThis.setTimeout; + globalThis.setTimeout = ((handler: () => void, delay?: number, ...rest: unknown[]) => { + if (delay !== undefined) delays.push(delay); + return original(handler, delay, ...rest); + }) as typeof setTimeout; + try { + let response = f.input.confirm("Confirm", "Proceed?", f.options); + await f.cards(1); + expect(questionLimits.INPUT_EXPIRY_MS).toBe(30 * 60 * 1_000); + expect(delays).toContain(questionLimits.INPUT_EXPIRY_MS); + f.controller.abort(); + expect(await response).toBe(false); + } finally { + globalThis.setTimeout = original; + } }); test("workflow identity labels each card while returned question text remains verbatim", async () => { @@ -303,17 +369,21 @@ test("blank dialog titles and supplied empty choices remain valid verbatim input }); /** Re-import the canonical document, proving the dialect accepts every card it holds. */ -async function documentQuestionnaires(source: string) { +async function documentCards(source: string) { let document = await Room.create(source); try { return document.editor.getEditorState().read(() => - $nodesOfType(QuestionnaireNode).map(node => node.getQuestionnaire().questions[0]!) + $nodesOfType(QuestionnaireNode).map(node => node.getQuestionnaire()) ); } finally { document.doc.destroy(); } } +async function documentQuestionnaires(source: string) { + return (await documentCards(source)).map(card => card.questions[0]!); +} + test("dialogs beyond the Planner's per-field limits become verbatim cards and answers", async () => { let f = await fixture(); let initial = "Keep this line of the plan.\n".repeat(180); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index 1f02910d..23de5d99 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -1,3 +1,5 @@ +import { limits } from "@chopin/question"; + import * as Questions from "../../questions/service"; import type { @@ -9,8 +11,16 @@ import type { } from "@bastani/atomic"; import type { DocumentRoom } from "../../agent/tools"; -/** Atomic owns input requests; Chopin owns their shared decision records. */ -export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostInput { +/** + * Atomic owns input requests; Chopin owns their shared decision records. + * + * A request nobody answers within `expiresInMs` expires: its cards stay in + * Decisions marked expired, and Atomic gets no answer. + */ +export function createHumanInput( + room: DocumentRoom, + expiresInMs = limits.INPUT_EXPIRY_MS, +): HostInput { async function questionnaire( params: QuestionParams, options: HostInputOptions, @@ -31,24 +41,19 @@ export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostIn multiple: question.multiSelect ?? false, })), }, { verbatim: true }); - let deadline = new AbortController(); - let signal = AbortSignal.any([options.signal, deadline.signal]); - let timer = timeoutMs === undefined ? undefined : setTimeout(() => deadline.abort(), timeoutMs); - let ended; - try { - ended = await Questions.ask( - room.plan, - room.server, - room.id, - definition, - undefined, - room.anchors, - signal, - ); - } finally { - clearTimeout(timer); + let ended = await Questions.ask( + room.plan, + room.server, + room.id, + definition, + undefined, + room.anchors, + options.signal, + expiresInMs, + ); + if (options.signal.aborted || ended.some(outcome => outcome.status === "expired")) { + return { answers: [], cancelled: true }; } - if (signal.aborted) return { answers: [], cancelled: true }; let answers: QuestionAnswer[] = []; ended.forEach((outcome, questionIndex) => { if (outcome.status !== "answered") return; diff --git a/apps/server/src/harness/atomic/workspace.ts b/apps/server/src/harness/atomic/workspace.ts new file mode 100644 index 00000000..a7e65772 --- /dev/null +++ b/apps/server/src/harness/atomic/workspace.ts @@ -0,0 +1,67 @@ +/** + * Where a channel's Atomic Planner works. + * + * A checkout is only ever supplied through `invoke_planner`, verified against + * the channel's repository, and remembered for the channel until the process + * exits. Every later Planner session for that channel re-verifies it before use. + * Without one, the session gets a directory Chopin created empty for that + * channel alone; it keeps whatever the Planner writes there until shutdown. + */ + +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { verifiedCheckout } from "./checkout"; + +export type PlannerWorkspace = { + cwd: string; + /** True when `cwd` is a verified checkout rather than the empty channel directory. */ + checkout: boolean; +}; + +let checkouts = new Map(); +let directories = new Map>(); + +export function rememberCheckout(channelId: string, checkout: string): void { + checkouts.set(channelId, checkout); +} + +export async function plannerWorkspace( + channelId: string, + repository: { owner: string; name: string }, +): Promise { + let remembered = checkouts.get(channelId); + let checkout = remembered === undefined + ? undefined + : await verifiedCheckout(repository, remembered); + if (checkout) return { cwd: checkout, checkout: true }; + return { cwd: await directory(channelId), checkout: false }; +} + +/** + * Chained per channel so concurrent sessions share one directory. `mkdtemp` + * creates it with mode 0700; one removed from under a running server is + * replaced rather than recreated at a name somebody else could have taken. + */ +function directory(channelId: string): Promise { + let previous = directories.get(channelId); + let next = (async () => { + let path = await previous?.catch(() => undefined); + if (path && await stat(path).then(found => found.isDirectory(), () => false)) return path; + return mkdtemp(join(tmpdir(), "chopin-planner-")); + })(); + directories.set(channelId, next); + return next; +} + +export async function removeWorkspaces(): Promise { + let paths = await Promise.all( + [...directories.values()].map(path => path.catch(() => undefined)), + ); + directories.clear(); + checkouts.clear(); + await Promise.all( + paths.map(path => path && rm(path, { recursive: true, force: true }).catch(() => {})), + ); +} diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index 54ca0c19..44228f29 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -1,10 +1,10 @@ import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; +import { removeWorkspaces } from "./atomic/workspace"; import { createCopilotSdk } from "./copilot-sdk/adapter"; import { createPiAdapter } from "./pi/adapter"; import type { HarnessV1 } from "@ai-sdk/harness"; import type { AtomicAuthMode } from "./atomic/adapter"; -import type { Config } from "../config"; const credentials = new Map string | undefined; @@ -18,13 +18,10 @@ function createPiHarness(settings: { auth?: string }): HarnessV1 & { shutdown(): } function createAtomicHarness( - settings: { auth?: string; fullPlanner?: boolean }, + settings: { auth?: string }, ): HarnessV1 & { shutdown(): Promise } { // Like Pi, Atomic takes neither a GitHub credential nor a credit limit. - return createAtomicAdapter({ - auth: settings.auth as AtomicAuthMode, - fullPlanner: settings.fullPlanner, - }); + return createAtomicAdapter({ auth: settings.auth as AtomicAuthMode }); } export const harnesses = { @@ -37,7 +34,6 @@ export const harnesses = { credentials: (id: string) => string | undefined; limits: (id: string) => { maxAiCredits: number } | undefined; auth?: string; - fullPlanner?: boolean; }) => HarnessV1 >; @@ -67,7 +63,6 @@ export function harnessFor(config: { harness: string; harnessAuth?: string; host?: string; - atomicPlanner?: Config["atomicPlanner"]; }): HarnessV1 { if (!Object.hasOwn(harnesses, config.harness)) { throw new Error(`Unknown harness: ${config.harness}`); @@ -99,7 +94,6 @@ export function harnessFor(config: { return maxAiCredits === undefined ? undefined : { maxAiCredits }; }, auth, - fullPlanner: !!config.atomicPlanner, }); } @@ -119,4 +113,5 @@ export async function shutdownHarnesses(): Promise { credentials.clear(); await selected?.shutdown(); selected = undefined; + await removeWorkspaces(); } diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index 35b6e353..0b198d26 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from "bun:test"; -import { openPlannerSession } from "./session"; -import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readdir, realpath, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { openPlannerSession } from "./session"; import { fullPlanner } from "./atomic/full"; +import { rememberCheckout, removeWorkspaces } from "./atomic/workspace"; import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; @@ -115,52 +116,105 @@ describe("openPlannerSession", () => { expect(result).toMatchObject({ ok: false, error: { kind: "Timeout" } }); expect(destroyed).toEqual({ sandbox: 1, session: 0, unregistered: 1 }); }); - it("registers full Planner input only for verified checkouts and releases it with the session", async () => { - let cwd = await mkdtemp(join(tmpdir(), "chopin-planner-session-")); - try { - for ( - let args of [["init", "--quiet"], ["remote", "add", "origin", "workgit:owner/repo.git"]] - ) { - expect( - await Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" }).exited, - ).toBe(0); - } - for (let checkouts of [[cwd], []]) { - let { owner, channel, deps } = fixture(); - let sessionId = ""; - let instructions = ""; - deps.agent = { - createSession: async (options: { sessionId: string }) => { - sessionId = options.sessionId; - return { destroy: async () => {} }; - }, - stream: async (call: { options: { instructions: string } }) => { - instructions = call.options.instructions; - }, - } as never; - let opened = await openPlannerSession(owner, { - ...channel, - atomicPlanner: { checkouts }, - instructions: checkout => plannerInstructions("owner/repo", "BOOTSTRAP", checkout), - }, deps); - expect(opened.ok).toBe(true); - if (!opened.ok) throw new Error("Planner unavailable"); - await opened.value.stream("prompt", new AbortController().signal); - expect(instructions).toContain("BOOTSTRAP"); - if (checkouts.length) { - expect(fullPlanner(sessionId)?.cwd).toBe(cwd); - expect(fullPlanner(sessionId)?.humanInput.questionnaire).toBeFunction(); - expect(instructions).toContain(cwd); - expect(instructions).not.toContain("You have no shell"); - } else { - expect(fullPlanner(sessionId)).toBeUndefined(); - expect(instructions).toContain("You have no shell"); - } - await opened.value.destroy(); - expect(fullPlanner(sessionId)).toBeUndefined(); - } - } finally { - await rm(cwd, { recursive: true, force: true }); +}); + +describe("Planner workspaces", () => { + let roots: string[] = []; + afterEach(async () => { + await removeWorkspaces(); + for (let root of roots.splice(0)) await rm(root, { recursive: true, force: true }); + }); + + async function checkout(origin: string): Promise { + let root = await mkdtemp(join(tmpdir(), "chopin-planner-checkout-")); + roots.push(root); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + expect( + await Bun.spawn(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }).exited, + ).toBe(0); + } + return realpath(root); + } + + /** Open and stream one Planner session, observing what the harness would see. */ + async function open(channelId: string, harness?: string) { + let { owner, channel, deps } = fixture(); + let sessionId = ""; + let instructions = ""; + let registered: ReturnType; + deps.agent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + registered = fullPlanner(sessionId); + }, + } as never; + let opened = await openPlannerSession(owner, { + ...channel, + room: { id: channelId, plan: {}, server: {} } as never, + harness, + instructions: workspace => plannerInstructions("owner/repo", "BOOTSTRAP", workspace), + }, deps); + if (!opened.ok) throw new Error("Planner unavailable"); + await opened.value.stream("prompt", new AbortController().signal); + await opened.value.destroy(); + expect(fullPlanner(sessionId)).toBeUndefined(); + expect(instructions).toContain("BOOTSTRAP"); + return { registered: registered!, instructions }; + } + + it("reuses a channel's remembered checkout for every later atomic session while it verifies", async () => { + let path = await checkout("workgit:owner/repo.git"); + rememberCheckout("channel", path); + for (let attempt of [1, 2]) { + let { registered, instructions } = await open("channel", "atomic"); + expect({ attempt, cwd: registered?.cwd }).toEqual({ attempt, cwd: path }); + expect(registered?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(`${path}, is a local checkout of owner/repo`); + expect(instructions).toContain("This is a full Atomic session"); + expect(instructions).not.toContain("You have no shell"); + } + let git = Bun.spawn(["git", "-C", path, "remote", "set-url", "origin", "workgit:other/repo"], { + stdout: "pipe", + stderr: "pipe", + }); + expect(await git.exited).toBe(0); + let fallback = await open("channel", "atomic"); + expect(fallback.registered?.cwd).not.toBe(path); + expect(await readdir(fallback.registered!.cwd)).toEqual([]); + expect(fallback.instructions).toContain("scratch directory Chopin created"); + }); + + it("gives each atomic channel without a checkout its own empty, private directory", async () => { + let first = await open("first", "atomic"); + let again = await open("first", "atomic"); + let second = await open("second", "atomic"); + expect(again.registered?.cwd).toBe(first.registered!.cwd); + expect(second.registered?.cwd).not.toBe(first.registered!.cwd); + for (let { registered, instructions } of [first, second]) { + let cwd = registered!.cwd; + expect((await stat(cwd)).mode & 0o777).toBe(0o700); + expect(await readdir(cwd)).toEqual([]); + expect(registered?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(`${cwd}, is a scratch directory Chopin created`); + expect(instructions).toContain("holds no repository files"); + expect(instructions).toContain("`read_repository_file`"); + expect(instructions).toContain("This is a full Atomic session"); + } + await removeWorkspaces(); + expect(await stat(first.registered!.cwd).catch(() => undefined)).toBeUndefined(); + }); + + it("keeps copilot-sdk and pi Planner sessions isolated, whatever the channel remembers", async () => { + rememberCheckout("channel", await checkout("workgit:owner/repo.git")); + for (let harness of ["copilot-sdk", "pi", undefined]) { + let { registered, instructions } = await open("channel", harness); + expect(registered).toBeUndefined(); + expect(instructions).toContain("You have no shell"); + expect(instructions).not.toContain("full Atomic session"); } }); }); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index 34f2823d..d02751a9 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -2,15 +2,14 @@ import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; import { plannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; -import { verifiedCheckout } from "./atomic/checkout"; import { registerFullPlanner } from "./atomic/full"; import { createHumanInput } from "./atomic/human-input"; +import { type PlannerWorkspace, plannerWorkspace } from "./atomic/workspace"; import type { HarnessAgent } from "@ai-sdk/harness/agent"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { HostedRepository } from "../agent/repository"; import type { DocumentRoom } from "../agent/tools"; -import type { Config } from "../config"; export type OpenError = | GitHubToolsError @@ -25,10 +24,10 @@ type PlannerAgent = typeof plannerAgent; export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string | ((checkout?: string) => string); + instructions: string | ((workspace?: PlannerWorkspace) => string); model?: string; - atomicPlanner?: Config["atomicPlanner"]; - checkout?: string; + /** `atomic` runs the session as a full Atomic session in the channel's workspace. */ + harness?: string; }; export type PlannerSession = { @@ -80,21 +79,17 @@ export async function openPlannerSession( if (owner.signal.aborted) throw new Error("Planner owner unavailable"); let sessionId = crypto.randomUUID(); unregister = (deps.registerCredential ?? registerCredential)(sessionId, owner.currentToken); - let cwd = channel.atomicPlanner - ? await verifiedCheckout( - channel.repository, - channel.atomicPlanner.checkouts, - channel.checkout, - ) + let workspace = channel.harness === "atomic" + ? await plannerWorkspace(channel.room.id, channel.repository) : undefined; - if (cwd) { + if (workspace) { unregisterFull = registerFullPlanner(sessionId, { - cwd, - humanInput: createHumanInput(channel.room, channel.atomicPlanner?.inputTimeoutMs), + cwd: workspace.cwd, + humanInput: createHumanInput(channel.room), }); } let instructions = typeof channel.instructions === "function" - ? channel.instructions(cwd) + ? channel.instructions(workspace) : channel.instructions; let agent = deps.agent ?? plannerAgent; let opening = agent.createSession({ sessionId, sandboxSession: sandbox }); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index dd20ccba..bf4b4c77 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -170,10 +170,10 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { }); } -/** A room's Chat, shared by browser messages and local MCP handoffs. */ +/** A room's Chat, shared by browser messages and MCP handoffs. */ function conversation( room: Rooms.Room, - claimantSessionId: string, + claimantSessionId: string | undefined, repository: Chat.Room["repository"], ): Chat.Room { return { @@ -1118,34 +1118,31 @@ registerMcpRoutes(router, hostedAuth, { return heldLease; }, }, { - invokePlanner: config.atomicPlanner - ? input => - withDocumentTransition(input.channel.id, async () => { - let channel = await storage.channels.get(input.channel.id); - if (!channel || deletingChannels.has(channel.id)) return "document-unavailable"; - if (channel.archivedAt) return "document-archived"; - await Rooms.get(channel.id)?.closing; - let held = Rooms.hold(channel.id); - let release = () => { - held.release(); - evict(held.room); - }; - let running = false; - try { - await plan(held.room, server); - let context = conversation(held.room, input.session.session.id, input.repository); - context.checkout = input.checkout; - let code = await Chat.invoke(context, input.session.user, input.instruction); - if (!code && context.chat.running) { - running = true; - void context.chat.running.finally(release).catch(() => {}); - } - return code; - } finally { - if (!running) release(); + invokePlanner: input => + withDocumentTransition(input.channel.id, async () => { + let channel = await storage.channels.get(input.channel.id); + if (!channel || deletingChannels.has(channel.id)) return "document-unavailable"; + if (channel.archivedAt) return "document-archived"; + await Rooms.get(channel.id)?.closing; + let held = Rooms.hold(channel.id); + let release = () => { + held.release(); + evict(held.room); + }; + let running = false; + try { + await plan(held.room, server); + let context = conversation(held.room, input.session?.session.id, input.repository); + let code = await Chat.invoke(context, input.user, input.instruction, input.checkout); + if (!code && context.chat.running) { + running = true; + void context.chat.running.finally(release).catch(() => {}); } - }) - : undefined, + return code; + } finally { + if (!running) release(); + } + }), archiveChannel, isChannelDeleting: channelId => deletingChannels.has(channelId), onChannelRenamed: announceChannel, diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index e4484ab3..d0ec91a9 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -486,9 +486,10 @@ export const TOOLS: Tool[] = [ }, { name: "invoke_planner", - description: "Post an instruction to a document's Planner as the locally signed-in user and " - + "return its URL without waiting for the turn. Available only in full Atomic mode; " - + "a supplied checkout must match the document repository. Questions appear in Decisions.", + description: "Post an instruction to a document's Planner, attributed to the caller, and " + + "return its URL without waiting for the turn. The turn runs under the document's " + + "Planner owner; without one, the caller's live Chopin browser login becomes the " + + "owner, and otherwise the call is refused. Questions appear in Decisions.", inputSchema: { type: "object", properties: { @@ -507,7 +508,9 @@ export const TOOLS: Tool[] = [ }, checkout: { type: "string", - description: "Absolute path to a local checkout of this repository.", + description: "Absolute path, on the Chopin server's machine, to a checkout of this " + + "repository. Used only by HARNESS=atomic, which verifies its origin and " + + "remembers it as the document's Planner working directory; ignored otherwise.", }, }, required: ["id", "instruction"], diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 97ec274c..236e9df0 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { mkdtemp, realpath, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { ulid } from "@chopin/dialect"; import { Sessions } from "../auth/session"; import { Admission } from "../auth/admission"; import { GitHubError } from "../github/client"; +import { handler } from "../mcp"; import * as Room from "../plan/room"; import * as Service from "../plan/service"; import * as Rooms from "../rooms"; @@ -208,75 +206,80 @@ async function plan(context: ReturnType) { } describe("the hosted MCP adapter", () => { - it("invokes only for a live local login of the MCP caller and verifies a supplied checkout", async () => { - let context = setup(); - context.auth.config.local = { - installation: "local", - credentialsDir: "/tmp/unused", - port: 8795, - }; - context.github.repositoryValue.permissions.push = true; - let opened = await plan(context); - let started: PlannerInvocation[] = []; - let adapter = hosted(context.auth, undefined, { - async invokePlanner(input) { - started.push(input); - return undefined; - }, - }); - let caller = (await adapter.caller(request("Bearer allowed")))!; - let input = { id: opened.channel.id, instruction: " Review this.\n" }; - try { - expect(hosted(context.auth).invoke).toBeUndefined(); - expect(await adapter.invoke!.invoke(caller, input)).toEqual({ - kind: "refused", - code: "planner-owner-unavailable", - }); - await context.storage.users.put({ - id: "U_other", - login: "other", - avatarUrl: "", - now: context.now, - }); - await context.auth.sessions.issue("U_other", grant("another-browser")); - expect(await adapter.invoke!.invoke(caller, input)).toEqual({ - kind: "refused", - code: "planner-owner-unavailable", - }); - let session = await context.auth.sessions.issue(caller.user.id, grant("browser-token")); - expect(await adapter.invoke!.invoke(caller, { ...input, checkout: "/does-not-exist" })) - .toEqual({ kind: "refused", code: "checkout-unverified" }); - expect(started).toEqual([]); - let result = await adapter.invoke!.invoke(caller, input); - expect(result).toEqual({ - kind: "invoked", - document: { - id: opened.channel.id, - title: opened.channel.title, - url: "https://chopin.test/documents/octo-org/score/release-readiness", + it("offers invoke_planner in hosted and local configuration, passing the caller and any live login", async () => { + for (let local of [false, true]) { + let context = setup(); + if (local) { + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + } + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + let started: PlannerInvocation[] = []; + let refusal: "planner-owner-unavailable" | undefined; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return refusal; }, }); - expect(started).toHaveLength(1); - expect(started[0]).toMatchObject({ - instruction: input.instruction, - session: { session: { id: session.id }, user: { id: caller.user.id } }, - }); - expect(started[0]!.checkout).toBeUndefined(); - } finally { - await Service.close(opened.plan); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let input = { id: opened.channel.id, instruction: " Review this.\n" }; + try { + expect(hosted(context.auth).invoke).toBeUndefined(); + let listed = await handler(adapter)( + new Request("https://chopin.test/mcp", { + method: "POST", + headers: { authorization: "Bearer allowed" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + expect((await listed.json()).result.tools.map((tool: { name: string }) => tool.name)) + .toContain("invoke_planner"); + await context.storage.users.put({ + id: "U_other", + login: "other", + avatarUrl: "", + now: context.now, + }); + await context.auth.sessions.issue("U_other", grant("another-browser")); + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "invoked", + document: { + id: opened.channel.id, + title: opened.channel.title, + url: "https://chopin.test/documents/octo-org/score/release-readiness", + }, + }); + expect(started[0]).toMatchObject({ + instruction: input.instruction, + user: { id: caller.user.id, login: caller.user.login }, + channel: { id: opened.channel.id }, + }); + expect(started[0]!.session).toBeUndefined(); + expect(Object.hasOwn(started[0]!, "checkout")).toBe(false); + let session = await context.auth.sessions.issue(caller.user.id, grant("browser-token")); + refusal = "planner-owner-unavailable"; + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "planner-owner-unavailable", + }); + expect(started[1]).toMatchObject({ + session: { session: { id: session.id }, user: { id: caller.user.id } }, + }); + } finally { + await Service.close(opened.plan); + } } }); - it("passes a matching checkout and local session through a URL invocation", async () => { + it("passes a URL invocation's checkout through unverified for the harness to judge", async () => { let context = setup(); - context.auth.config.local = { - installation: "local", - credentialsDir: "/tmp/unused", - port: 8795, - }; context.github.repositoryValue.permissions.admin = true; let opened = await plan(context); - let checkout = await mkdtemp(join(tmpdir(), "chopin-mcp-checkout-")); let started: PlannerInvocation[] = []; let adapter = hosted(context.auth, undefined, { async invokePlanner(input) { @@ -286,51 +289,23 @@ describe("the hosted MCP adapter", () => { }); try { let caller = (await adapter.caller(request("Bearer allowed")))!; - let session = await context.auth.sessions.issue(caller.user.id, grant("local-browser-token")); - for ( - let args of [["init", "--quiet"], [ - "remote", - "add", - "origin", - "workgit:other/repository.git", - ]] - ) { - let git = Bun.spawn(["git", "-C", checkout, ...args], { stdout: "pipe", stderr: "pipe" }); - expect(await git.exited).toBe(0); - } let input = { id: "https://chopin.test/documents/octo-org/score/release-readiness", instruction: "Review", - checkout, + checkout: "/does-not-exist", }; - expect(await adapter.invoke!.invoke(caller, input)).toEqual({ - kind: "refused", - code: "checkout-unverified", - }); - expect(started).toHaveLength(0); - let git = Bun.spawn([ - "git", - "-C", - checkout, - "remote", - "set-url", - "origin", - "workgit:octo-org/score.git", - ], { stdout: "pipe", stderr: "pipe" }); - expect(await git.exited).toBe(0); expect(await adapter.invoke!.invoke(caller, input)).toMatchObject({ kind: "invoked", document: { id: opened.channel.id, url: input.id }, }); expect(started).toHaveLength(1); expect(started[0]).toMatchObject({ - checkout: await realpath(checkout), + checkout: "/does-not-exist", instruction: input.instruction, - session: { session: { id: session.id } }, + repository: { id: "R_score", owner: "octo-org", name: "score" }, }); } finally { await Service.close(opened.plan); - await rm(checkout, { recursive: true, force: true }); } }); @@ -346,12 +321,6 @@ describe("the hosted MCP adapter", () => { }, isChannelDeleting: () => deleting, }; - expect(hosted(context.auth, undefined, callbacks).invoke).toBeUndefined(); - context.auth.config.local = { - installation: "local", - credentialsDir: "/tmp/unused", - port: 8795, - }; let adapter = hosted(context.auth, undefined, callbacks); let caller = (await adapter.caller(request("Bearer allowed")))!; let invoke = (id = opened.channel.id) => diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 9661b247..29b97940 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -8,7 +8,6 @@ import * as Rooms from "../rooms"; import { claimImplementation, reportImplementationLifecycle } from "../tasks/plan-graphs"; import { StorageError } from "../storage/errors"; import { implementationLifecycle } from "../tasks/lifecycle"; -import { verifiedCheckout } from "../harness/atomic/checkout"; import type { Server } from "bun"; import type { HostedAuth } from "../auth/routes"; @@ -37,8 +36,11 @@ export type HostedCaller = { export type PlannerInvocation = { channel: ChannelRecord; repository: HostedRepository; - session: AuthenticatedSession; + user: GitHubUser; + /** The caller's live browser login, which may claim ownership for a channel without one. */ + session?: AuthenticatedSession; instruction: string; + /** Unverified; only the atomic harness reads it. */ checkout?: string; }; @@ -268,7 +270,7 @@ export function hosted( } return { - invoke: auth.config.local && callbacks.invokePlanner + invoke: callbacks.invokePlanner ? { async invoke(caller, input) { let located = await locatedChannel(caller, input.id); @@ -287,20 +289,13 @@ export function hosted( }; } if (channel.archivedAt) return { kind: "refused", code: "document-archived" }; - let session = await auth.sessions.forUser(caller.user.id); - if (!session) return { kind: "refused", code: "planner-owner-unavailable" }; - let checkout = input.checkout === undefined - ? undefined - : await verifiedCheckout(repository, [], input.checkout); - if (input.checkout !== undefined && !checkout) { - return { kind: "refused", code: "checkout-unverified" }; - } let code = await callbacks.invokePlanner!({ channel, repository, - session, + user: caller.user, + session: await auth.sessions.forUser(caller.user.id), instruction: input.instruction, - checkout, + ...(input.checkout === undefined ? {} : { checkout: input.checkout }), }); if (code) return { kind: "refused", code }; return { diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index cb21a07b..70c42ce7 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -545,6 +545,19 @@ export function appendQuestionOption( }); } +/** Mark host input nobody answered in time as expired, leaving the card where it is. */ +export function projectExpiry(target: Document, id: string, at: string): Mutation | undefined { + return mutate(target, () => { + let found = false; + for (let node of $nodesOfType(QuestionnaireNode)) { + if (node.getId() !== id) continue; + found = true; + node.setQuestionnaire({ ...node.getQuestionnaire(), status: "expired", at }); + } + return found; + }); +} + /** Take a questionnaire out of the plan, leaving its record as history. */ export function removeQuestionnaire(target: Document, id: string): Mutation | undefined { return mutate(target, () => { diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index a6a448a7..78588607 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -418,8 +418,7 @@ function restoredState( let questions = objects(item.questions, "question record"); for (let question of questions) { if ( - (question.status !== "open" && question.status !== "answered" - && question.status !== "cancelled") + !["open", "answered", "cancelled", "expired"].includes(question.status as string) || !question.definition || typeof question.definition !== "object" || Array.isArray(question.definition) diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index fee8d248..a50bf5ef 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -67,7 +67,7 @@ export function identify(raw: unknown, options?: { verbatim?: boolean }): Defini export type Record = { id: string; definition: Definition; - status: "open" | "answered" | "cancelled"; + status: "open" | Wire.Status; /** Question id to the answer as it reads, for projection into the plan. */ answers?: { [question: string]: string }; resolver?: string; @@ -95,7 +95,12 @@ function decide( return out; } -/** Ask each decision independently; register its record before publishing its node. */ +/** + * Ask each decision independently; register its record before publishing its node. + * + * An abort withdraws the cards still open. After `expiresInMs` without an answer + * they expire instead, and stay in the document marked as such. + */ export async function ask( plan: Plan, server: Server, @@ -104,6 +109,7 @@ export async function ask( placement?: AskPlacement, created?: () => void, signal?: AbortSignal, + expiresInMs?: number, ): Promise { if (signal?.aborted) return []; if (Service.implementationActive(plan)) throw new Error("implementation is active"); @@ -177,20 +183,29 @@ export async function ask( }); let failed = Promise.withResolvers(); - let withdrawing: Promise | undefined; - let abort = () => { - withdrawing ??= Promise.all( - asked.map(item => withdraw(plan, server, roomId, item.id, "chopin")), + let closing: Promise | undefined; + let close = (status: "cancelled" | "expired") => { + closing ??= Promise.all( + asked.map(item => + status === "expired" + ? expire(plan, server, roomId, item.id) + : withdraw(plan, server, roomId, item.id, "chopin") + ), ); - void withdrawing.catch(failed.reject); + void closing.catch(failed.reject); }; + let abort = () => close("cancelled"); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) abort(); + let timer = expiresInMs === undefined + ? undefined + : setTimeout(() => close("expired"), expiresInMs); try { let ended = await Promise.race([Promise.all(asked.map(item => item.waiting)), failed.promise]); - await withdrawing; + await closing; return ended; } finally { + clearTimeout(timer); signal?.removeEventListener("abort", abort); } } @@ -557,26 +572,68 @@ export async function cancel( } /** Withdraw input on behalf of a member or the Planner, after its durable commit. */ -export async function withdraw( +export function withdraw( plan: Plan, server: Server, roomId: string, id: string, resolver: string, ): Promise { + return closeUnanswered( + plan, + server, + roomId, + id, + { status: "cancelled", resolver }, + () => room.removeQuestionnaire(plan.document, id), + ); +} + +/** + * Expire host input nobody answered in time, after its durable commit. + * + * Unlike a withdrawal the card stays in the document, marked expired, so the + * room can still see what was asked and that nobody answered it. + */ +export function expire( + plan: Plan, + server: Server, + roomId: string, + id: string, +): Promise { + let at = Math.floor(Date.now() / 1_000); + return closeUnanswered( + plan, + server, + roomId, + id, + { status: "expired", resolver: "chopin", at }, + () => room.projectExpiry(plan.document, id, new Date(at * 1_000).toISOString()), + ); +} + +async function closeUnanswered( + plan: Plan, + server: Server, + roomId: string, + id: string, + closed: { status: "cancelled" | "expired"; resolver: string; at?: number }, + project: () => room.Mutation | undefined, +): Promise { + let { status, resolver } = closed; return Service.exclusive(plan, async () => { - let claimed = Store.claimCancel(plan.questions, id, resolver); + let claimed = Store.claimCancel(plan.questions, id, resolver, status); if (!claimed.ok) return claimed; let mutation: room.Mutation | undefined; try { - mutation = room.removeQuestionnaire(plan.document, id); + mutation = project(); } catch (err) { - console.error("[questions] could not remove the node:", err); + console.error(`[questions] could not project the ${status} node:`, err); Store.rollback(plan.questions, claimed.claim); return { ok: false, reason: "resolving" }; } let record = plan.records.get(id); - if (record) plan.records.set(id, { ...record, status: "cancelled", resolver }); + if (record) plan.records.set(id, { ...record, ...closed }); let finish = Store.stage(plan.questions, claimed.claim); try { if (mutation) await Service.publish(plan, server, roomId, mutation); @@ -589,13 +646,7 @@ export async function withdraw( throw err; } finish(); - broadcast(server, roomId, { - kind: "question:resolved", - ts: 0, - id, - status: "cancelled", - resolver, - }); + broadcast(server, roomId, { kind: "question:resolved", ts: 0, id, status, resolver }); return { ok: true, resolver }; }); } diff --git a/apps/server/src/questions/store.ts b/apps/server/src/questions/store.ts index 32069839..250aa7c1 100644 --- a/apps/server/src/questions/store.ts +++ b/apps/server/src/questions/store.ts @@ -30,7 +30,7 @@ export type Collaborator = { export type Ended = | { status: "answered"; answers: Answer[]; resolver: string } - | { status: "cancelled"; resolver: string }; + | { status: "cancelled" | "expired"; resolver: string }; type Open = { id: string; @@ -297,7 +297,7 @@ export function away(questions: Questions, client: string): string[] { export type Settled = { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Ended["status"]; resolver: string; answers?: Answer[]; }; @@ -367,10 +367,12 @@ export function claimSubmit( }; } +/** Reserve an unanswered close: a cancellation, or an expiry nobody answered in time. */ export function claimCancel( questions: Questions, id: string, resolver: string, + status: "cancelled" | "expired" = "cancelled", ): { ok: true; claim: Claim; widget?: string } | CancelRefusal { let ended = questions.closed.get(id); if (ended) return resolved(ended); @@ -385,7 +387,7 @@ export function claimCancel( return { ok: true, ...(entry.widget ? { widget: entry.widget } : {}), - claim: { id, entry, result: { status: "cancelled", resolver } }, + claim: { id, entry, result: { status, resolver } }, }; } diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index a6340fe6..85424b20 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -31,7 +31,7 @@ export type Room = { closing?: Promise; /** Pending eviction, cancelled if somebody comes back. */ eviction?: ReturnType; - /** Local MCP work keeps a memberless room alive until its turn and queue finish. */ + /** MCP work keeps a memberless room alive until its turn and queue finish. */ holds?: number; }; diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts new file mode 100644 index 00000000..22c62468 --- /dev/null +++ b/apps/server/src/testing/config.ts @@ -0,0 +1,49 @@ +import { load } from "../config"; + +export const REQUIRED = { + STORAGE_DRIVER: "postgres", + DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", + APP_ORIGIN: "https://chopin.example", + GITHUB_APP_SLUG: "chopin-test", + GITHUB_APP_CLIENT_ID: "client-id", + GITHUB_APP_CLIENT_SECRET: "client-secret", + SESSION_ENCRYPTION_KEY: "11".repeat(32), +}; + +/** Local-mode overrides for a loopback instance. */ +export const LOCAL = { + AUTH_MODE: "local", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", +}; + +/** Load configuration from exactly these variables, restoring the environment afterwards. */ +export function configured(overrides: Record = {}) { + let env: Record = { + ...REQUIRED, + AGENT: undefined, + BACKGROUND_JOBS: undefined, + WEB_RESEARCH: undefined, + HARNESS: undefined, + HARNESS_AUTH: undefined, + AUTH_MODE: undefined, + SERVER_HOST: undefined, + PORT: undefined, + MODEL: undefined, + CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, + GITHUB_ALLOWED_USERS: undefined, + GITHUB_ALLOWED_ORGANIZATIONS: undefined, + ...overrides, + }; + let previous = { ...process.env }; + for (let [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return load(); + } finally { + for (let key of Object.keys(env)) delete process.env[key]; + Object.assign(process.env, previous); + } +} diff --git a/apps/server/src/testing/decisions.ts b/apps/server/src/testing/decisions.ts index 2897838b..ba0879a5 100644 --- a/apps/server/src/testing/decisions.ts +++ b/apps/server/src/testing/decisions.ts @@ -12,7 +12,7 @@ import type { DocumentRoom } from "../agent/tools"; import type { Socket, SocketData } from "../wire"; /** A room whose Decisions answer Atomic HostInput requests, with a member to answer them. */ -export async function hostInputRoom(timeoutMs?: number) { +export async function hostInputRoom(expiresInMs?: number) { let storage = new MemoryStorage(); let now = new Date(); await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); @@ -60,7 +60,7 @@ export async function hostInputRoom(timeoutMs?: number) { sessionId: "session", signal: controller.signal, }; - let input = createHumanInput(room, timeoutMs); + let input = createHumanInput(room, expiresInMs); async function cards(count: number) { for (let deadline = Date.now() + 4_000; Date.now() < deadline; await Bun.sleep(5)) { if (Store.outstanding(plan.questions).length === count) { diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 069e0628..6ea1a406 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -11,6 +11,7 @@ import { QuestionnaireStore, selectDecisionView, ThreadStore, + undecided, useHasPlanContent, useQuestionnaires, visibleDecisionView, @@ -300,9 +301,7 @@ export function RoomWorkspace( localStorage.setItem("chopin:view:document", next); } if (next === "decisions" && revealFirst) { - let first = entries.find(entry => - entry.value.questions.some(question => question.answer === undefined) - ); + let first = entries.find(undecided); setReveal({ widget: first?.id ?? "", token: Date.now() }); } }; diff --git a/docs/architecture.md b/docs/architecture.md index dd109b3c..44519291 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,8 +119,11 @@ request becomes that channel's Planner owner for the lifetime of the process session. Permission callbacks recheck admission, session identity, credential revision, ownership generation, repository role, and App installation before execution. -The Planner has bounded, repository-fixed read tools and no ambient checkout, -shell, or host filesystem; no harness built-in is active. Model-backed +Under `copilot-sdk` and `pi` the Planner has bounded, repository-fixed read tools +and no ambient checkout, shell, or host filesystem; no harness built-in is active. +Under `atomic` every Planner session is a +[full Atomic session](hosted-agent.md#full-atomic-planner) with shell and +filesystem access as the server process's user. Model-backed `active-planner` workers use the same owner credential but fresh isolated harness sessions; see [Background jobs](background-jobs.md). diff --git a/docs/authentication.md b/docs/authentication.md index afd14dab..21b99180 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -202,12 +202,10 @@ Each browser has its own binding; configured instance admission lists still apply to every authorized account. Loopback binding does not make this a mode for internet-facing or exposed multi-user deployments. -`ATOMIC_PLANNER=full` additionally enables local Atomic tools and resources for -Planner sessions with a verified repository checkout. It requires `HARNESS=atomic` -and grants shell and filesystem access as the operator; loopback is not a filesystem -sandbox. Both Atomic auth modes (`auto` and `ai-gateway`) are allowed. Keep this a -trusted single-operator instance, with no public proxy or tunnel. See -[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode). +The authentication mode does not change the Planner's tools. Under +`HARNESS=atomic`, every Planner session, local or hosted, has shell and +filesystem access as the server process's user; loopback binding is not a +filesystem sandbox. See [Full Atomic Planner](hosted-agent.md#full-atomic-planner). ## Instance admission @@ -261,6 +259,12 @@ lookup. An MCP-created document for a repository outside the App installation is not available through browser routes, WebSockets, or the hosted agent until the installation includes that repository. See [Local agent MCP](local-agent-mcp.md). +`invoke_planner` is the one MCP tool that starts a hosted agent turn. It never +lends the caller's bearer to the Planner: the turn runs under the channel's +existing Planner owner, and a channel without one is claimed only for the +caller's own live browser login, which must already have passed the +installation-gated owner checks. + The authorization-code flow uses state, S256 PKCE, the exact configured callback, and the App client secret. It does not request OAuth scopes; permissions come from the App registration and each installation. The setup diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index f94ad313..182e8f33 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -3,8 +3,9 @@ Chopin's Copilot-backed document agent is currently named Planner. It can inspect one selected GitHub repository, co-author the shared document, ask the participants structured questions, and anchor decisions to prose. For documents -used as plans, it can also draft an implementation graph. By default it does not -implement code or change GitHub; the local full Atomic mode below changes its tool boundary. +used as plans, it can also draft an implementation graph. Under `copilot-sdk` and +`pi` it does not implement code or change GitHub; under `atomic` it runs as a full +Atomic session, described [below](#full-atomic-planner). The product role is document co-authoring. The current prompt and tool vocabulary remain optimized for planning and may structure another document type as a plan; @@ -15,9 +16,14 @@ from a code-owned map, defaulting to `copilot-sdk`, a host-process adapter over `@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, and `atomic`, which embeds Atomic's headless SDK (`@bastani/atomic`) in the server process, are further reviewed adapters. Both require an explicit `HARNESS_AUTH`. The -harness owns the agent loop and model; by default Chopin owns every tool the Planner -can call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` -selection and adapter trust. +harness owns the agent loop and model. Under `copilot-sdk` and `pi`, Chopin owns +every tool the Planner can call. + +**Choosing `HARNESS=atomic` gives the Planner shell and filesystem access as the +server process's user, on hosted instances as well as local ones.** No other flag +turns this on or off. Operators who do not want it should use `copilot-sdk` or +`pi`. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness) for +`HARNESS`/`HARNESS_AUTH` selection and adapter trust. ## Ownership @@ -36,6 +42,12 @@ user without Copilot entitlement sees the provider failure on the first model-backed action and remains owner until one of those release conditions occurs. +An MCP [`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) +call follows the same rules. Its instruction is posted as the caller's own +message; the turn runs under the channel's current owner, whoever that is. A +channel without one is claimed for the caller's live browser login, hosted or +local, and without such a login the call is refused and nothing is posted. + PostgreSQL stores the owner session ID only so durable ownership can refer to an active process session. The cookie verifier and GitHub credential remain in memory. Startup clears every browser-session registry row and owner reference, @@ -44,16 +56,16 @@ ownership generation. ## Runtime isolation -By default every harness receives only Chopin's host-executed tools: document, question, -relationship, implementation-graph, repository, and GitHub tools, each bound to -the channel's repository through `toolsContext`. No harness built-in is active -for the Planner. The default `copilot-sdk` adapter additionally runs the shared -Copilot runtime in SDK `mode: "empty"`: each disposable session receives its -owner's token when created and has no client-level service token or -logged-in-user fallback. That detail is specific to the Copilot SDK adapter, -not a property every harness in `HARNESS` shares. +Under `copilot-sdk` and `pi` the Planner receives only Chopin's host-executed +tools: document, question, relationship, implementation-graph, repository, and +GitHub tools, each bound to the channel's repository through `toolsContext`. No +harness built-in is active for the Planner. The default `copilot-sdk` adapter +additionally runs the shared Copilot runtime in SDK `mode: "empty"`: each +disposable session receives its owner's token when created and has no +client-level service token or logged-in-user fallback. That detail is specific to +the Copilot SDK adapter, not a property every harness in `HARNESS` shares. -The isolated Planner has no: +That isolated Planner has no: - checkout, shell, or host filesystem; - skills, plugins, or configuration discovery; @@ -65,20 +77,7 @@ Under `HARNESS=pi`, Chopin patches `@ai-sdk/harness-pi` 1.0.128 so Pi does not load `AGENTS.md` or `CLAUDE.md` context files from the host filesystem. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness). -Under `HARNESS=atomic` without a verified full-mode checkout, each harness session -owns one in-process Atomic `AgentSession`. It is built with every shipped Atomic package disabled -(workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding -tools, and its resource loader discovers no extensions, skills, prompt -templates, themes, or context files. Its working and configuration directory is -an empty private temporary directory, and its session, settings, and -credentials stay in memory. A per-turn hook replaces the whole system prompt -with the turn's instructions, so the model never receives Atomic's -coding-agent preamble. The adapter fails the turn before any model request -when the live session reports an extension, tool, context file, skill, prompt -template, or system prompt beyond that set. It checks the tools offered to the -model again before every model request. - -Available capabilities are: +Chopin's tools available to the Planner under every harness are: - Chopin document, question, relationship, and implementation-graph tools (with current plan-oriented tool names); @@ -103,41 +102,59 @@ returned by Chopin with its checkout before claiming work. The server validates the shape of creation provenance but does not resolve its branch and commit against GitHub or independently inspect the coding agent's checkout. -## Full Atomic Planner in local mode - -`ATOMIC_PLANNER=full` is an explicit trust change, accepted only with -`AUTH_MODE=local` and `HARNESS=atomic`. Other combinations and unrecognized -nonempty values fail startup. Both `HARNESS_AUTH=auto` and `ai-gateway` work. -This gives the Planner shell and filesystem access **as the local operator**, -not a sandbox confined to a repository. Use it only on a trusted single-operator, -loopback-only instance; never expose that instance through a proxy or tunnel. - -Set `ATOMIC_PLANNER_CHECKOUTS` to absolute checkout paths separated by the -platform path delimiter (`:` on Unix, `;` on Windows). Before each Planner -session, Chopin checks candidate paths with Git and compares `origin`'s -owner/repository to the channel repository, case-insensitively. HTTPS, -`ssh://`, and scp-style remotes are accepted. Remote host spellings are ignored -because SSH aliases are common; this verifies repository coordinates, not the -authenticity of a remote host. A per-invocation candidate takes precedence over -the configured list. No verified checkout means the same isolated session as -before, with no Atomic resources or HostInput binding. - -A local coding agent can supply that candidate with -[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). -The tool requires a live browser login matching its MCP caller, posts a durable -member instruction, and returns the document URL without waiting for the turn. -An explicitly supplied but unverified checkout is refused before posting, rather -than falling back to another configured path. - -With a verified checkout, it becomes the session's working directory. Atomic's -workflows, subagents, MCP, web access, Intercom, and default coding tools run -alongside Chopin's document tools. The normal Atomic agent directory (including -`ATOMIC_CODING_AGENT_DIR` or the legacy `PI_CODING_AGENT_DIR` override) supplies -extensions, skills, prompt templates, context files, and a read-only copy of its -settings. Chopin appends its Planner instructions to Atomic's assembled prompt. -Session, settings, and model credentials remain in memory; Atomic's own enabled -tools, extensions, MCP servers, and workflow storage can perform local writes. -Background summary and research workers still use isolated sessions. +Under `HARNESS=atomic` the Planner is a [full Atomic session](#full-atomic-planner), +but the summary and research workers each still own one isolated in-process +Atomic `AgentSession`. It is built with every shipped Atomic package disabled +(workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding +tools, and its resource loader discovers no extensions, skills, prompt +templates, themes, or context files. Its working and configuration directory is +an empty private temporary directory, and its session, settings, and +credentials stay in memory. A per-turn hook replaces the whole system prompt +with the turn's instructions, so the model never receives Atomic's +coding-agent preamble. The adapter fails the turn before any model request +when the live session reports an extension, tool, context file, skill, prompt +template, or system prompt beyond that set. It checks the tools offered to the +model again before every model request. + +## Full Atomic Planner + +Under `HARNESS=atomic` every Planner session is a full Atomic session, in local +and hosted deployments alike, with no separate flag. This gives the Planner +shell and filesystem access **as the server process's user**, not a sandbox +confined to a repository. Operators who do not want that should use +`copilot-sdk` or `pi`. Both `HARNESS_AUTH=auto` and `ai-gateway` work, under +their usual [bind rules](self-hosting.md#choose-and-trust-a-harness). + +Atomic's workflows, subagents, MCP, web access, Intercom, and default coding +tools run alongside Chopin's document and repository tools. The normal Atomic +agent directory (including `ATOMIC_CODING_AGENT_DIR` or the legacy +`PI_CODING_AGENT_DIR` override) supplies extensions, skills, prompt templates, +context files, and a read-only copy of its settings. Chopin appends its Planner +instructions to Atomic's assembled prompt. Session, settings, and model +credentials remain in memory; Atomic's own enabled tools, extensions, MCP +servers, and workflow storage can perform writes as the server process's user. +Background summary and research workers still use the isolated sessions described +above. + +The working directory comes only from the `checkout` argument of +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner). +Chopin checks the path with Git and compares `origin`'s owner/repository to the +document's repository, case-insensitively. HTTPS, `ssh://`, and scp-style +remotes are accepted. Remote host spellings are ignored because SSH aliases are +common; this verifies repository coordinates, not the authenticity of a remote +host. An unverified path refuses the invocation before anything is posted. A +verified one is remembered in memory for that document until the process exits, +and every later Planner session for the document, browser-started or MCP-started, +re-verifies it before using it as its working directory. + +Without a remembered checkout that still verifies, the session runs with the +same tools in an empty working directory Chopin creates for that document alone, +under the operating system's temporary directory with mode `0700`. It is never +shared between documents, keeps the Planner's own files for later sessions of the +same document, and is removed when the server shuts down cleanly. The Planner's +instructions state which case applies: a verified checkout, or an empty +directory with no repository files, in which case it reads the repository through +Chopin's repository tools. Chopin implements Atomic's `HostInput`, bound through `extensionBindings.humanInput`; it does not intercept tools. `ask_user_question`, @@ -160,11 +177,15 @@ extension dialogs, and workflow-stage input become ordinary shared Decisions: The label is added to the card only; Atomic receives its question text as asked. A request is appended as one adjacent batch at the end of the document. - An abort withdraws still-open cards, removes their document nodes, and records - cancellation by `@chopin` in Decisions. Member cancellations retain any answers - already given in that batch. Late submissions cannot approve withdrawn input. -- `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` optionally limits each request (a positive - integer in milliseconds, at most 2147483647). Unset waits indefinitely. Expiry - withdraws pending cards and returns no answer; confirm returns false. + cancellation by `@chopin`. Member cancellations retain any answers already + given in that batch. Late submissions cannot approve withdrawn input. +- A request nobody answers within 30 minutes expires. Its still-open cards stay + in the document and in Decisions, among the resolved cards, marked + `status="expired"`; each reads "Nobody answered within 30 minutes. The + Planner will use its best judgement for this decision." Nobody can answer an + expired card. Atomic receives no answer: a questionnaire comes back cancelled + with no answers, confirm returns false, and select, input, and editor return + nothing. The limit is fixed in code, not configured. Host input is not held to the Planner `ask` tool's per-field limits on question and option counts or header, question, label, description, and answer lengths. @@ -176,6 +197,12 @@ workflow approvals pending on withdrawal; Chopin does not automatically resume workflows or replay interrupted turns after a restart. A new Atomic session must explicitly resume a saved run. +A coding agent can hand an instruction to the Planner with +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) under +any harness. The instruction is posted as the MCP caller's own message and runs +under the document's Planner ownership rules (see [Ownership](#ownership)). +Harnesses other than `atomic` ignore its `checkout`. + ## Permission checks Before each Chopin host tool or its repository-bound GitHub MCP tool executes, callbacks recheck: @@ -190,8 +217,9 @@ Before each Chopin host tool or its repository-bound GitHub MCP tool executes, c Permission is decided before execution. A refusal therefore produces no normal tool start or completion event; the Chat service renders permission denials explicitly so the boundary remains visible. -These checks do not mediate full mode's Atomic coding tools, operator extensions, -or operator-configured MCP servers; those use the operator's local authority. +These checks do not mediate the atomic Planner's Atomic coding tools, operator +extensions, or operator-configured MCP servers; those act with the server +process's own authority. A harness session is bound to one credential revision. Before an eight-hour GitHub App token refresh, Chopin aborts and discards every Planner session diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index 9b6c24f3..97296170 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -26,8 +26,8 @@ Chopin authenticates the supplied token, applies the instance admission policy, and asks GitHub directly for that token's repository permissions. The GitHub App for Chopin does not need to be installed for ordinary MCP document operations. Browser routes, WebSockets, and the Planner still require an active App -installation that includes the repository. The local-only `invoke_planner` -handoff uses an existing browser login for that Planner boundary, not the MCP bearer. +installation that includes the repository. The `invoke_planner` handoff never +lends the MCP bearer to the Planner; it runs under the document's Planner owner. ```bash export CHOPIN_URL="https://your-chopin-instance.example" @@ -53,9 +53,9 @@ The current MCP contract can: - rename, archive, and restore documents without deleting their durable state; - read an approved implementation graph and its document source; and - claim a graph and report task, pull-request, blocker, revision, and - verification lifecycle transitions. - -Local instances with `ATOMIC_PLANNER=full` also offer `invoke_planner`, described below. + verification lifecycle transitions; and +- hand an instruction to a document's Planner with `invoke_planner`, described + below. Chopin validates the shape of `baseBranch` and `baseCommit` during creation but does not resolve them against GitHub. The creating agent is responsible for @@ -72,21 +72,27 @@ graph, but the product has no user-facing way to approve the Planner's draft. See [Experimental implementation lifecycle](implementation-lifecycle.md). -## Hand an instruction to the local Planner +## Hand an instruction to the Planner + +`invoke_planner` is offered on every harness and in both hosted and local +authentication modes. It posts an instruction to an existing document's Planner +and returns without waiting for the turn. + +The instruction is posted as a Chat message under the MCP caller's own handle, +and the turn runs under the document's Planner ownership rules, exactly as a +browser `@chopin` message would: -`invoke_planner` is available only with `ATOMIC_PLANNER=full`, which requires -`AUTH_MODE=local` and `HARNESS=atomic`. See -[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for setup -and the trust boundary. A coding agent using this tool can drive a Planner with -**shell and filesystem access as the local user**. Keep the instance loopback-only -and single-operator; do not expose it to untrusted clients. +- If the document already has a Planner owner, the turn runs under that owner, + even when the owner is someone other than the caller. +- Otherwise the caller's live Chopin browser login, hosted or local, claims + ownership through the ordinary claim path. Sign in to Chopin in a browser as + the same GitHub account as the MCP bearer first. +- Without either, the call is refused with `planner-owner-unavailable` and + nothing is posted. -Sign in to Chopin through the local device flow as the same GitHub account used -by the MCP bearer. The most recently issued live browser session for that account -supplies the Planner claimant. An existing Planner owner must belong to that same -account; the MCP bearer never becomes the owner or supplies its credentials. -Both the bearer and the browser login need repository write access, and the -browser login must pass the GitHub App installation check. +The MCP bearer never becomes the owner or supplies its credentials. The bearer +needs repository write access; an owner must also pass the GitHub App +installation check. Call the tool with an existing document's UUID or canonical URL, an instruction, and optionally an absolute checkout path: @@ -100,25 +106,36 @@ and optionally an absolute checkout path: ``` The instruction must contain non-whitespace text and fit within 64 KiB in UTF-8; -its text is preserved verbatim. A supplied checkout must have an `origin` matching -the document repository or the request is refused before posting. The verified -path applies to this turn only, including when queued. If omitted, the configured -`ATOMIC_PLANNER_CHECKOUTS` list applies; without a verified checkout the Planner -keeps its isolated behavior. +its text is preserved verbatim. + +`checkout` applies only when the server runs `HARNESS=atomic`, where the Planner +is a [full Atomic session](hosted-agent.md#full-atomic-planner) with **shell and +filesystem access as the server process's user**. The path is on the Chopin +server's machine. Chopin verifies that its `origin` matches the document +repository and refuses the request before posting if it does not. A verified +path is remembered for the document until the server restarts, and every later +Planner session for it, from the browser or through MCP, works there after +checking the path again. Without one, the Planner works in an empty directory +Chopin keeps for that document. Other harnesses ignore `checkout` entirely: it is +neither verified, used, nor remembered. The result contains `id`, `title`, and the canonical `url` (plus any generated `description`). It returns after the instruction is durably posted, not after the -Planner finishes. The message appears in Chat under the local user's handle and -joins the existing Planner queue if busy. Progress and results appear in the -document and Chat; Atomic questions appear in Decisions. The browser need not -already have the document open: Chopin keeps its room alive while the invoked -turn and queue run. An interrupted turn is not replayed automatically on restart. +Planner finishes. The message joins the existing Planner queue if busy. Progress +and results appear in the document and Chat; questions appear in Decisions. Under +`atomic`, Atomic input nobody answers within 30 minutes expires: the card stays in +Decisions marked expired and the Planner proceeds on its own judgement. The +browser need not already have the document open: Chopin keeps its room alive +while the invoked turn and queue run. An interrupted turn is not replayed +automatically on restart. Refusals return an object with `code`: -- `planner-owner-unavailable`: sign in locally as the MCP account and check the - existing Planner owner and browser repository access. -- `checkout-unverified`: correct the supplied path or its repository origin. +- `planner-owner-unavailable`: the document has no usable Planner owner and the + caller has no live browser login to claim it; sign in to Chopin in a browser + as the MCP account, or ask the owner to sign in again. +- `checkout-unverified`: under `atomic`, correct the supplied path or its + repository origin. - `planner-unavailable`: the Planner is disabled or the room is closing. - `planner-queue-full`: wait for queued work to drain before invoking again. - `document-unavailable`, `document-archived`, and `repository-forbidden` retain @@ -133,7 +150,7 @@ The `url` returned by `create_document` is the readable canonical route: ``` `read_document`, `read_implementation`, `archive_document`, `restore_document`, -`update_document`, and (in full mode) `invoke_planner` accept either a document UUID +`update_document`, and `invoke_planner` accept either a document UUID or that canonical URL in their `id` input. The URL may be passed back exactly as returned; an absolute URL must use the configured Chopin origin. Both reads return the stable UUID as the document `id`. @@ -278,8 +295,8 @@ lacks the operation's repository permission; it does not mean the GitHub App for Chopin must be installed. Pull access is enough for `list_documents`, `read_document`, and `read_implementation`. Pull plus push or admin access is required for create, update, rename, archive, restore, invoke, start, and report -lifecycle operations. `invoke_planner` additionally requires the local browser -login described above. Use an account with the required access or ask a repository +lifecycle operations. `invoke_planner` additionally needs a Planner owner or the +caller's live browser login, as described above. Use an account with the required access or ask a repository owner to grant it. Use the optional diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 3c23bde0..4bcdde7d 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -117,9 +117,18 @@ covers this against the real Pi agent loop; run it before bumping `HARNESS=atomic` runs Atomic 0.9.25 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use -RPC mode, or substitute Atomic for Pi's runtime. By default each harness session owns -one in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see -[Hosted agent](hosted-agent.md#runtime-isolation) for isolation and its local full-mode exception. +RPC mode, or substitute Atomic for Pi's runtime. + +**Choosing `HARNESS=atomic` gives the Planner shell and filesystem access as the +server process's user, on hosted instances as well as local ones.** Every Planner +session is a full Atomic session: Atomic's workflows, subagents, MCP, web access, +Intercom, and default coding tools run beside Chopin's document tools, with the +operator's Atomic extensions, skills, prompt templates, and context files. There is +no separate flag; choosing the harness is the choice. Operators who do not want that +should use `copilot-sdk` or `pi`, whose Planner keeps the isolated, Chopin-tools-only +boundary. Only the summary and research workers still run isolated Atomic sessions. +See [The atomic Planner](#the-atomic-planner) below and +[Hosted agent](hosted-agent.md#full-atomic-planner) for the details. `HARNESS=atomic` requires an explicit `HARNESS_AUTH`, either `auto` or `ai-gateway`. Unset, `direct`, and every other value are refused at startup. @@ -164,15 +173,16 @@ Atomic caveats: enables its shipped packages, including a mandatory Intercom, and its coding tools. It discovers host resources, gives turns without instructions a coding-agent system prompt, and saves tool results over 50,000 characters to a - temporary file. The isolated adapter overrides each default and fails closed on the - ones it can observe, but a new Atomic release can add a default the suite does - not check. Local full mode deliberately enables those capabilities. + temporary file. For the isolated worker sessions the adapter overrides each + default and fails closed on the ones it can observe, but a new Atomic release + can add a default the suite does not check. Planner sessions deliberately keep + those capabilities. - Sessions live only in memory. `doStop` returns a state the adapter refuses to resume, so a session never survives a restart. Chopin does not resume harness sessions. - Harness-level compaction, suspending a turn, and supplied harness skills are - unsupported. Full-mode resource discovery does load Atomic skills. Atomic's - automatic retries remain on. + unsupported. A Planner session's own resource discovery does load Atomic + skills. Atomic's automatic retries remain on. - Under `auto`, refreshing an OAuth token in memory can rotate the refresh token stored by the host CLI, which may then ask the operator to sign in again. `auto` also runs `!command` API-key entries in `auth.json` to resolve them. @@ -191,6 +201,44 @@ usage alerts. A Copilot credit limit applies to one harness session, including a turns of its job stage, not as a platform-wide budget; see [Background jobs](background-jobs.md#executor-owned-limits). +### The atomic Planner + +A Planner session's working directory comes from one place: a `checkout` path +supplied through the MCP +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) tool. +Chopin checks it with Git and compares `origin`'s owner/repository to the +document's repository, ignoring case and the remote host spelling (so SSH host +aliases work). A mismatching or missing path refuses the invocation before +anything is posted. A verified path is remembered for that document until the +server process exits; nothing is written to storage. Every later Planner session +for the document, whether started from the browser or through MCP, re-verifies +the remembered path before using it. The check establishes repository +coordinates, not remote-host authenticity, and it does not confine the shell. + +Without a remembered checkout that still verifies, the session runs in an empty +working directory that Chopin creates for that document alone under the operating +system's temporary directory, with mode `0700`. It is never shared with another +document, keeps what the Planner writes there for later sessions of the same +document, and is removed when the server shuts down cleanly. The session has the +same tools either way; the Planner is told whether it is in a checkout or in an +empty directory without repository files, and its repository tools remain +available. + +Atomic input (`ask_user_question`, extension dialogs, and workflow-stage +questions) appears as shared Decisions instead of terminal dialogs, including +workflow run and stage labels. Unanchored batches append at the document's end. +Dialog text and written answers stay verbatim, bounded only by the document's +256 KiB source limit and the shared-draft limits rather than the Planner's own +question limits. A request nobody answers within 30 minutes expires: its cards +stay in Decisions marked as expired, and Atomic receives no answer. Cancellation +withdraws pending cards; neither ever approves an action. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner) for the mapping, +resource loading, persistence, and workflow restart boundaries. + +`invoke_planner` itself is offered on every harness and in both authentication +modes. Other harnesses ignore its `checkout`: they neither verify nor use nor +remember it. + ## Prerequisites - Docker for the application image, or Bun 1.4.2 for a source deployment. @@ -239,31 +287,28 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| --------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | -| `ATOMIC_PLANNER` | unset | Set exactly `full` to allow full Atomic Planner sessions. Requires `AUTH_MODE=local` and `HARNESS=atomic`; every other nonempty value or combination fails startup. Both Atomic auth modes are supported. | -| `ATOMIC_PLANNER_CHECKOUTS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. With no list, only an `invoke_planner` checkout can enable a full session. | -| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| Variable | Default | Meaning | +| ------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. `atomic` gives every Planner session shell and filesystem access as the server process's user, hosted instances included; see [Choose and trust a harness](#choose-and-trust-a-harness). | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. @@ -420,49 +465,6 @@ App authorization, because device-issued refresh tokens can be refreshed without the client secret but GitHub's revocation endpoint requires it. Remove the App from **Settings > Applications** on GitHub to revoke it there. -### Opt into a full Atomic Planner - -Add these settings to the local-mode source deployment above, with an explicit -Atomic `MODEL` and either `HARNESS_AUTH=auto` or `HARNESS_AUTH=ai-gateway`: - -```bash -export HARNESS=atomic -export HARNESS_AUTH=auto -export MODEL="github-copilot/gpt-6-luna" -export ATOMIC_PLANNER=full -export ATOMIC_PLANNER_CHECKOUTS="/absolute/path/to/checkout" -# Optional: withdraw a request after ten minutes without a complete answer. -export ATOMIC_PLANNER_INPUT_TIMEOUT_MS=600000 -``` - -This is not the hosted tool boundary: verified Planner sessions enable Atomic's -shell, filesystem, builtins, and the operator's normal extensions, skills, -templates, and context files. The process runs with the operator's local -privileges. Local mode's loopback restriction and single-operator trust are -essential; never expose it publicly or forward it to untrusted users. - -Before a session starts, Chopin verifies that its cwd is a Git work tree whose -`origin` owner/repository matches the channel, ignoring case and remote host -spelling (so SSH host aliases work). A mismatching or unavailable checkout -falls back to the default isolated behavior, not to the server's cwd. -The check does not establish remote-host authenticity or confine shell access. - -Atomic input appears as shared Decisions instead of terminal dialogs, including -workflow run/stage labels. Unanchored batches append at the document's end. -Dialog text and written answers stay verbatim, bounded only by the document's -256 KiB source limit and the shared-draft limits rather than the Planner's own -question limits. Cancellation or timeout withdraws pending cards and never -approves an action. See -[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) -for mapping, resource loading, persistence, and workflow restart boundaries. - -This mode also exposes the MCP -[`invoke_planner` handoff](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). -Sign in locally as the MCP caller before using it. A supplied `checkout` is verified -before the instruction is posted; an invalid explicit path refuses the invocation. -The tool returns a document URL immediately while the Planner continues in Chat -and asks questions in Decisions. - ## First-start smoke test Startup validates configuration, database connectivity, migration history, the diff --git a/packages/dialect/src/convert.test.ts b/packages/dialect/src/convert.test.ts index eafab152..53edbd15 100644 --- a/packages/dialect/src/convert.test.ts +++ b/packages/dialect/src/convert.test.ts @@ -5,8 +5,12 @@ import { $getRoot, $isElementNode } from "lexical"; import { $createPlanNodes, exportPlan, importPlan } from "./convert"; import { $isCodeBlockNode } from "./nodes/content"; +import { toElement, unanswered } from "./nodes/questionnaire"; import { parse } from "./parse"; import { registry } from "./registry"; +import { serialize } from "./serialize"; + +import type { QuestionnaireNode } from "./nodes/questionnaire"; import type { LexicalEditor } from "lexical"; @@ -105,6 +109,34 @@ describe("conversion", () => { expect(instance.getEditorState().read(() => $getRoot().getFirstChild()!.getKey())).toBe(key); }); + it("round-trips an expired questionnaire's marker through its Lexical node", () => { + let value = { + id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + status: "expired" as const, + at: "2026-09-28T10:30:00.000Z", + questions: [{ + id: "01K0N4V4E7Y6P4MJ5WD8XZF3B2", + header: "Confirm", + prompt: "Ship it?", + multiple: false, + options: [{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Yes" }], + }], + }; + let source = serialize({ type: "root", children: [toElement(value)] }); + expect(source).toStartWith( + ``, + ); + expect(through(source)).toBe(source); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + let imported = instance.getEditorState().read(() => + ($getRoot().getFirstChild() as QuestionnaireNode).getQuestionnaire() + ); + expect(imported).toEqual(value); + expect(unanswered(imported)).toEqual([]); + expect(unanswered({ ...imported, status: undefined })).toEqual(imported.questions); + }); + it("round-trips tables through native nodes", () => { let source = "| Name | Status |\n| ---- | ------ |\n| API | Ready |\n"; expect(through(source)).toBe(source); diff --git a/packages/dialect/src/dialect.test.ts b/packages/dialect/src/dialect.test.ts index e492d8ac..82771cac 100644 --- a/packages/dialect/src/dialect.test.ts +++ b/packages/dialect/src/dialect.test.ts @@ -174,6 +174,19 @@ describe("components", () => { ); }); + it("accepts only `expired` as a questionnaire status", () => { + let questionnaire = (status: string) => + `\n` + + `\n` + + `\n` + + ``; + accepts(questionnaire("expired")); + for (let status of ["cancelled", "answered", "open", "Expired"]) { + expect(codes(questionnaire(status))).toContain("bad-attribute-value"); + } + }); + it("accepts an accepted comment thread", () => { accepts( `\n` diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index 33233e59..24b107a1 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -92,6 +92,9 @@ export const COMPONENTS: Readonly> = Object.freeze({ * * Both are optional: a questionnaire has neither until it is answered, and * one settled before this was recorded has neither for good. + * + * `status="expired"` marks host input nobody answered within its time limit. + * It stays in the document, unanswered, with `at` saying when it expired. */ Questionnaire: component({ name: "Questionnaire", @@ -99,6 +102,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "components", names: ["Question"] }, forbids: ["Questionnaire", "Tabs", "Callout"], attributes: { + status: { type: "enum", required: false, values: ["expired"] }, by: { type: "text", required: false, max: limits.MAX_HANDLE }, at: { type: "text", required: false, max: limits.MAX_TIMESTAMP }, }, diff --git a/packages/dialect/src/index.ts b/packages/dialect/src/index.ts index 8b2d659f..6a82a68a 100644 --- a/packages/dialect/src/index.ts +++ b/packages/dialect/src/index.ts @@ -75,6 +75,7 @@ export { $isQuestionnaireNode, QuestionnaireNode, toElement as questionnaireElement, + unanswered, } from "./nodes/questionnaire"; export type { Option, Question, Questionnaire } from "./nodes/questionnaire"; diff --git a/packages/dialect/src/nodes/questionnaire.ts b/packages/dialect/src/nodes/questionnaire.ts index 911f5797..60474111 100644 --- a/packages/dialect/src/nodes/questionnaire.ts +++ b/packages/dialect/src/nodes/questionnaire.ts @@ -47,12 +47,15 @@ export type Question = { export type Questionnaire = { id: string; questions: Question[]; + /** Host input nobody answered in time: settled, but with no answer. */ + status?: "expired"; /** * Who settled it, and when. * * On the questionnaire rather than on each answer: it resolves as a unit, * so this is one fact about one moment. Absent until it is answered, and - * absent for good on one answered before this was written down. + * absent for good on one answered before this was written down. An expired + * questionnaire has only `at`. */ by?: string; /** ISO 8601. */ @@ -67,11 +70,18 @@ function parse(value: unknown): Questionnaire { return { id: typeof raw.id === "string" ? raw.id : "", questions: Array.isArray(raw.questions) ? raw.questions : [], + ...(raw.status === "expired" ? { status: raw.status } : {}), ...(typeof raw.by === "string" ? { by: raw.by } : {}), ...(typeof raw.at === "string" ? { at: raw.at } : {}), }; } +/** Questions still waiting for somebody; an expired questionnaire waits for nobody. */ +export function unanswered(value: Questionnaire): Question[] { + if (value.status === "expired") return []; + return value.questions.filter(question => question.answer === undefined); +} + export const questionnaireState = createState("plan-questionnaire", { parse, isEqual: (a: Questionnaire, b: Questionnaire) => JSON.stringify(a) === JSON.stringify(b), @@ -196,6 +206,7 @@ export function fromElement(node: Jsx): Questionnaire { return { id: attribute(node, "id") ?? "", questions, + ...(attribute(node, "status") === "expired" ? { status: "expired" as const } : {}), ...(by ? { by } : {}), ...(at ? { at } : {}), }; @@ -206,7 +217,7 @@ export function toElement(value: Questionnaire): MdxJsxFlowElement { return { type: "mdxJsxFlowElement", name: "Questionnaire", - attributes: identity(value.id, { by: value.by, at: value.at }), + attributes: identity(value.id, { status: value.status, by: value.by, at: value.at }), children: value.questions.map(question => ({ type: "mdxJsxFlowElement", name: "Question", diff --git a/packages/editor/src/decision-state.test.ts b/packages/editor/src/decision-state.test.ts index 1ea763c5..df02981d 100644 --- a/packages/editor/src/decision-state.test.ts +++ b/packages/editor/src/decision-state.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "bun:test"; -import { advanceDecisionView, countUnanswered, selectDecisionView, visibleDecisionView } from "."; +import { + advanceDecisionView, + countUnanswered, + selectDecisionView, + undecided, + visibleDecisionView, +} from "."; import type { DecisionViewState } from "."; @@ -29,6 +35,14 @@ describe("decision attention", () => { .toBe(2); }); + it("never waits on an expired questionnaire", () => { + let waiting = entry([undefined]); + let expired = { ...waiting, value: { ...waiting.value, status: "expired" as const } }; + expect([undecided(waiting), undecided(expired), undecided(entry(["Done"]))]) + .toEqual([true, false, false]); + expect(countUnanswered([expired, entry([undefined, undefined])])).toBe(2); + }); + it("forces only a questionnaire-only opening document into Decisions", () => { expect(visibleDecisionView({ phase: "initial", preferred: "plan" }, false, 2)).toBe( "decisions", diff --git a/packages/editor/src/decision-state.ts b/packages/editor/src/decision-state.ts index ac7d629d..50899206 100644 --- a/packages/editor/src/decision-state.ts +++ b/packages/editor/src/decision-state.ts @@ -1,3 +1,5 @@ +import { unanswered } from "@chopin/dialect"; + import type { QuestionnaireEntry } from "./questionnaires"; export type DecisionView = "plan" | "decisions"; @@ -9,11 +11,12 @@ export type DecisionViewState = { }; export function countUnanswered(entries: QuestionnaireEntry[]): number { - return entries.reduce( - (total, entry) => - total + entry.value.questions.filter(question => question.answer === undefined).length, - 0, - ); + return entries.reduce((total, entry) => total + unanswered(entry.value).length, 0); +} + +/** Whether a questionnaire still waits for the room; answered and expired ones do not. */ +export function undecided(entry: QuestionnaireEntry): boolean { + return unanswered(entry.value).length > 0; } /** A forced opening yields to Plan only when prose first arrives. */ diff --git a/packages/editor/src/decisions.test.tsx b/packages/editor/src/decisions.test.tsx index 46e2c68d..76d90994 100644 --- a/packages/editor/src/decisions.test.tsx +++ b/packages/editor/src/decisions.test.tsx @@ -6,15 +6,31 @@ import { Decisions } from "./decisions"; import { QuestionnaireStore } from "./questionnaires"; import type { MotionDisclosureContract } from "./disclosure-motion"; +import type { QuestionnaireEntry } from "./questionnaires"; let original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const RESOLVED: QuestionnaireEntry = { + id: "questionnaire", + value: { + id: "questionnaire", + questions: [{ + id: "question", + header: "Question", + prompt: "What did the room decide?", + multiple: false, + options: [], + answer: "Done", + }], + }, +}; + afterEach(() => { if (original) Object.defineProperty(globalThis, "localStorage", original); else delete (globalThis as { localStorage?: unknown }).localStorage; }); -function markup(stored?: string): string { +function markup(stored?: string, entries = [RESOLVED]): string { let values = new Map(); if (stored) values.set("chopin:decisions:resolved", stored); Object.defineProperty(globalThis, "localStorage", { @@ -26,23 +42,7 @@ function markup(stored?: string): string { }); let store = new QuestionnaireStore(); - store.set({ - entries: [{ - id: "questionnaire", - value: { - id: "questionnaire", - questions: [{ - id: "question", - header: "Question", - prompt: "What did the room decide?", - multiple: false, - options: [], - answer: "Done", - }], - }, - }], - hasPlanContent: true, - }); + store.set({ entries, hasPlanContent: true }); let motion: MotionDisclosureContract = { className: "motion-collapse", closeDuration: 250, @@ -64,3 +64,31 @@ test("resolved history starts collapsed and restores an explicit open preference expect(restored).toContain('data-feedback-icon="open"'); expect(restored).toContain('data-motion-feedback="icon"'); }); + +test("an expired card is listed with the resolved cards and says nobody answered", () => { + let expired: QuestionnaireEntry = { + id: "expired", + value: { + id: "expired", + status: "expired", + at: "2026-09-28T10:30:00.000Z", + questions: [{ + id: "confirm", + header: "Confirm", + prompt: "Ship the migration?", + multiple: false, + options: [{ id: "yes", label: "Yes" }], + }], + }, + }; + let history = markup("true", [expired, RESOLVED]); + expect(history).toMatch(/2<\/span>resolved<\/span>/); + expect(history).toContain('data-plan-sidecar-questionnaire="expired"'); + expect(history).toContain("Ship the migration?"); + expect(history).toContain( + "Nobody answered within 30 minutes. The Planner will use its best judgement for this decision.", + ); + for (let control of ["Save answer", " question.answer === undefined); -} - let HISTORY = "chopin:decisions:resolved"; /** Resolved history stays closed unless the reader explicitly opened it. */ diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 1e2497de..513ab804 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -11,6 +11,7 @@ export { advanceDecisionView, countUnanswered, selectDecisionView, + undecided, visibleDecisionView, } from "./decision-state"; export type { DecisionView, DecisionViewState, OpeningPhase } from "./decision-state"; diff --git a/packages/editor/src/widgets/questionnaire.tsx b/packages/editor/src/widgets/questionnaire.tsx index c250b2a0..a9f9f811 100644 --- a/packages/editor/src/widgets/questionnaire.tsx +++ b/packages/editor/src/widgets/questionnaire.tsx @@ -77,6 +77,7 @@ export function QuestionnaireCard( wire, }: QuestionnaireCardProps, ) { + if (value.status === "expired") return ; let resolved = answers(value); let pointing = { places, onQuestionEnter, onQuestionLeave, onQuestionSelect }; @@ -252,6 +253,20 @@ export function carriedByMarkers( ); } +/** Host input nobody answered in time: settled like an answer, with nothing to submit. */ +function Expired({ value }: { value: Questionnaire }) { + return ( + + + + ); +} + function InlineQuestionnaire({ value }: { value: Questionnaire }) { let options = useCellValue(widgets$); // Re-render when anchors arrive: whether the card collapses depends on them. diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index 422cf634..5399d8ce 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -183,7 +183,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -203,7 +203,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -270,10 +270,20 @@ export declare namespace Question { by: string; }; + /** + * How a questionnaire closed. + * + * `cancelled` takes the card out of the document: a member declined it or + * its asker withdrew it. `expired` is host input nobody answered within its + * time limit; the card stays in the document, marked expired, and its asker + * proceeds without an answer. + */ + export type Status = "answered" | "cancelled" | "expired"; + /** The questionnaire is closed. Nobody may answer it further. */ export type Resolved = KIND<"question:resolved"> & { id: string; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; }; diff --git a/packages/question/src/limits.ts b/packages/question/src/limits.ts index e53e1ced..6c3556bf 100644 --- a/packages/question/src/limits.ts +++ b/packages/question/src/limits.ts @@ -28,3 +28,6 @@ export const MAX_MODEL_BYTES = 256 * 1024; /** Longest a tool call id may be, since it identifies the request. */ export const MAX_CALL_ID = 256; + +/** How long host input waits for an answer before its card expires unanswered. */ +export const INPUT_EXPIRY_MS = 30 * 60 * 1_000; diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index d2825dd0..6bd6c704 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -3,7 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { currentQuestion, QuestionView } from "./question-view"; -import { create, normalize, read } from "../index"; +import { create, limits, normalize, read } from "../index"; test("a replacement definition falls back before rendering when its active question disappears", () => { let storage = { @@ -263,3 +263,38 @@ test("only the Planner's own questions cap the custom answer textarea", () => { expect(markup.includes('maxLength="4000"')).toBe(cap); } }); + +test("an expired card keeps its question and says the Planner will proceed; a withdrawn one does not", () => { + let definition = normalize({ + questions: [{ + header: "Confirm", + question: "Ship the migration?", + options: [{ label: "Yes", description: "" }], + multiple: false, + }], + }); + let render = (status: "expired" | "cancelled") => + renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: {}, + status, + resolver: "chopin", + onChange() {}, + onSubmit() {}, + onCancel() {}, + })); + let note = `Nobody answered within ${limits.INPUT_EXPIRY_MS / 60_000} minutes. ` + + "The Planner will use its best judgement for this decision."; + let expired = render("expired"); + expect(limits.INPUT_EXPIRY_MS).toBe(30 * 60 * 1_000); + expect(expired).toContain( + "Nobody answered within 30 minutes. The Planner will use its best judgement for this decision.", + ); + expect(expired).toContain(note); + expect(expired).toContain("Ship the migration?"); + expect(expired).not.toContain("Cancelled"); + for (let control of [" Promise; disabled?: boolean; submitting?: boolean; - status?: "open" | "answered" | "cancelled"; + status?: "open" | Question.Status; /** Shown instead of controls once the questionnaire has resolved. */ answers?: Answer[]; resolver?: string; @@ -512,6 +513,25 @@ function Callout( ); } +const EXPIRED_NOTE = `Nobody answered within ${ + INPUT_EXPIRY_MS / 60_000 +} minutes. The Planner will use its best judgement for this decision.`; + +/** + * Host input whose time ran out. Unlike a cancelled card it stays in the + * document, so it keeps what was asked beside the note saying nobody answered. + */ +function Expired({ definition }: { definition: Definition }) { + return ( +
+ {definition.questions.map(question => ( +

{question.question}

+ ))} +

{EXPIRED_NOTE}

+
+ ); +} + export function QuestionView(props: QuestionViewProps) { let { definition, @@ -568,14 +588,17 @@ export function QuestionView(props: QuestionViewProps) { else if (target === "primary") primary.current?.focus(); }, [active]); - // A cancelled questionnaire has no answers, so it must be matched on status - // alone — falling through would offer an editable form for a dead question. - if (status === "cancelled") { + // A cancelled or expired questionnaire has no answers, so it must be matched + // on status alone — falling through would offer an editable form for a dead + // question. + if (status === "cancelled" || status === "expired") { return (
{single && } {aside} - + {status === "expired" + ? + : }
); } From 733c6509cc0ae2ff5a91bebb9a65e5a65cfb59b6 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Tue, 29 Sep 2026 08:13:29 -0700 Subject: [PATCH 13/17] Load a verified checkout's project settings in the full Planner The full Planner kept the operator's settings in an in-memory manager with no project scope, so packages a repository installs for itself (`atomic install -l`, recorded in `.atomic/settings.json`) never loaded, and their workflows and tools were missing from Planner turns run in that checkout. Seed the in-memory project scope from the working directory's `.atomic/settings.json` as trusted project settings, keep both files unwritten, and reapply Chopin's compaction, summary, and cache overrides. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: 1749 tests, 2 PostgreSQL skips, 0 fail, including a new full-Planner test that a checkout's project package contributes its tool and skill without writing either settings file Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, type scale, Impeccable baseline Assistant-verification: local E2E failed before this fix: invoke_planner reached a full Planner whose tools lacked a package installed through the checkout's .atomic/settings.json User-preference: HARNESS=atomic runs the Planner as a full Atomic session, so a checkout should behave like a local Atomic session in that repository Co-authored-by: Alex Lavaee --- apps/server/src/harness/atomic/adapter.ts | 40 +++++++++++++---- apps/server/src/harness/atomic/full.test.ts | 48 ++++++++++++++++++++- docs/hosted-agent.md | 6 ++- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index 3df1c50c..81f1f31f 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -72,6 +72,37 @@ const SETTINGS = { sessionSummary: { enabled: false }, cacheWarming: "off" as const, }; + +async function optionalFile(path: string): Promise { + return readFile(path, "utf8").catch(error => { + if (error.code === "ENOENT") return undefined; + throw error; + }); +} + +/** + * The operator's settings plus the working directory's project settings, held in + * memory so the session never writes either file. A verified checkout's + * `.atomic/settings.json` can add project packages the way it does for a local + * Atomic session; the empty per-channel directory has none. + */ +async function fullSettings(agentDir: string, cwd: string): Promise { + let scopes: Record<"global" | "project", string | undefined> = { + global: JSON.stringify({ + ...JSON.parse(await optionalFile(join(agentDir, "settings.json")) ?? "{}"), + ...SETTINGS, + }), + project: await optionalFile(join(cwd, ".atomic", "settings.json")), + }; + let manager = SettingsManager.fromStorage({ + withLock(scope, fn) { + let next = fn(scopes[scope]); + if (next !== undefined) scopes[scope] = next; + }, + }, { projectTrusted: true }); + manager.applyOverrides(SETTINGS); + return manager; +} const ZERO_USAGE = { inputTokens: { total: undefined, @@ -438,14 +469,7 @@ export function createAtomicAdapter( if (!full) directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); let cwd = full?.cwd ?? directory!; let agentDir = full ? getAgentDir() : directory!; - if (full) { - let file = join(agentDir, "settings.json"); - let saved = await readFile(file, "utf8").catch(error => { - if (error.code === "ENOENT") return "{}"; - throw error; - }); - settingsManager = SettingsManager.inMemory({ ...JSON.parse(saved), ...SETTINGS }); - } + if (full) settingsManager = await fullSettings(agentDir, cwd); sessionManager ??= SessionManager.inMemory(cwd); let loader = new DefaultResourceLoader({ cwd, diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index ace21b68..dd31ba08 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -64,12 +64,14 @@ afterAll(stub.stop); async function run( registered: boolean, prompt = "plain", - { host, checkout = true, worker = false }: { + { host, checkout = true, worker = false, projectPackage = false }: { host?: HostInput; /** False runs the Planner in an empty directory, as a channel without a checkout does. */ checkout?: boolean; /** Also run an unregistered worker session on the same harness while the Planner is open. */ worker?: boolean; + /** Install a local package through the checkout's `.atomic/settings.json`. */ + projectPackage?: boolean; } = {}, ) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); @@ -125,6 +127,28 @@ async function run( }); expect(await git.exited).toBe(0); } + if (projectPackage) { + let pkg = join(root, "project-package"); + await mkdir(join(pkg, "skills", "project-marker"), { recursive: true }); + await writeFile( + join(pkg, "package.json"), + JSON.stringify({ + name: "project-package", + type: "module", + atomic: { extensions: ["./extension.ts"], skills: ["./skills"] }, + }), + ); + await writeFile( + join(pkg, "extension.ts"), + `export default api => api.registerTool({name: "project_tool", label: "Project", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"project"}], details:{}}; }});`, + ); + await writeFile( + join(pkg, "skills", "project-marker", "SKILL.md"), + "---\nname: project-marker\ndescription: PROJECT-SKILL-MARKER\n---\nProject skill.\n", + ); + await mkdir(join(cwd, ".atomic"), { recursive: true }); + await writeFile(join(cwd, ".atomic", "settings.json"), JSON.stringify({ packages: [pkg] })); + } process.env.ATOMIC_CODING_AGENT_DIR = agentDir; let humanInput: HostInput = host ?? { confirm: async () => false, @@ -166,7 +190,15 @@ async function run( await result.consumeStream(); await result.text; let requests = [...stub.requests]; - if (!worker) return { cwd, received, requests, workerRequests: [] }; + if (!worker) { + let files = { + operator: await Bun.file(join(agentDir, "settings.json")).exists(), + project: projectPackage + ? await Bun.file(join(cwd, ".atomic", "settings.json")).text() + : undefined, + }; + return { cwd, received, requests, workerRequests: [], files }; + } let workerSession = await agent.createSession({ sandboxSession: sandbox }); try { stub.requests.length = 0; @@ -236,6 +268,18 @@ test("a Planner session without a checkout is just as full in its empty working expect(result.requests.at(-1)!.toolResults.join("\n")).toContain(result.cwd); }); +test("a checkout's project settings add its packages without writing either settings file", async () => { + let result = await run(true, "plain", { projectPackage: true }); + let request = result.requests[0]!; + expect(request.toolNames).toContain("project_tool"); + expect(request.system).toContain("PROJECT-SKILL-MARKER"); + for (let name of FULL_TOOLS) expect(request.toolNames).toContain(name); + expect(result.files!.operator).toBe(false); + expect(JSON.parse(result.files!.project!).packages).toHaveLength(1); + let plain = await run(true); + expect(plain.requests[0]!.toolNames).not.toContain("project_tool"); +}); + test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { let room = await hostInputRoom(); try { diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 182e8f33..e69e329c 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -129,7 +129,11 @@ Atomic's workflows, subagents, MCP, web access, Intercom, and default coding tools run alongside Chopin's document and repository tools. The normal Atomic agent directory (including `ATOMIC_CODING_AGENT_DIR` or the legacy `PI_CODING_AGENT_DIR` override) supplies extensions, skills, prompt templates, -context files, and a read-only copy of its settings. Chopin appends its Planner +context files, and a read-only copy of its settings. A verified checkout's +`.atomic/settings.json` is read the same way, as trusted project settings, so +packages installed for that project load too; the empty per-channel directory +has none. Chopin's compaction, summary, and cache overrides still apply on top. +Chopin appends its Planner instructions to Atomic's assembled prompt. Session, settings, and model credentials remain in memory; Atomic's own enabled tools, extensions, MCP servers, and workflow storage can perform writes as the server process's user. From e4a1e534ac561f6ec61bf4bd0cd327dfdee20ca3 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Tue, 29 Sep 2026 23:05:13 -0700 Subject: [PATCH 14/17] Keep the Planner's workspace instructions neutral Drop the full Atomic session framing and the tool inventory from the Planner's workspace instructions; the system prompt already lists its tools. Keep only what the model cannot infer: questions from ask_user_question and workflows appear as Decisions, and an expired question means proceeding on best judgement. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-duration: 12m converged, estimated 10m Assistant-verification: bun test passed: apps/server/src/harness, chat and agent, 302 pass, 0 fail Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run ci passed: dprint, oxlint, tokens, type scale and design checks User-preference: Planner prompts stay neutral; no "full Atomic session" meta framing, name tools only where it adds information the system prompt lacks Co-authored-by: Alex Lavaee --- apps/server/src/agent/planner.ts | 9 +++------ apps/server/src/chat/invoke.test.ts | 2 +- apps/server/src/harness/session.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index 1f646f34..5373f16a 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -252,10 +252,7 @@ repository tools read.` : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created empty for this document. It is not a checkout and holds no repository files, so read ${repository} through the repository tools.`; - let full = `This is a full Atomic session: you have the operator's Atomic coding tools, workflows, -subagents and resources, with shell and filesystem access as the server process's user. -Atomic human input, including workflow questions, goes to Chopin Decisions. Input nobody -answers in time expires with no answer; then proceed on your best judgement and say what -you assumed. Chopin's document tools remain fixed to this document.`; - return [PROMPT, reading, place, full, bootstrap].filter(Boolean).join("\n\n"); + let questions = `\`ask_user_question\` and \`workflow\` questions appear to the document's members as +Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`; + return [PROMPT, reading, place, questions, bootstrap].filter(Boolean).join("\n\n"); } diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts index 78fd750e..a20c02a5 100644 --- a/apps/server/src/chat/invoke.test.ts +++ b/apps/server/src/chat/invoke.test.ts @@ -405,7 +405,7 @@ test("HARNESS=atomic runs every Planner session full in hosted and local configu let harness = context.config.harness; expect({ harness, full: !!turn.full }).toEqual({ harness, full }); expect(!!turn.full?.humanInput).toBe(full); - expect(turn.instructions.includes("This is a full Atomic session")).toBe(full); + expect(turn.instructions.includes("proceed on your best judgement")).toBe(full); } } }); diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index 0b198d26..f9573744 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -174,7 +174,7 @@ describe("Planner workspaces", () => { expect({ attempt, cwd: registered?.cwd }).toEqual({ attempt, cwd: path }); expect(registered?.humanInput.questionnaire).toBeFunction(); expect(instructions).toContain(`${path}, is a local checkout of owner/repo`); - expect(instructions).toContain("This is a full Atomic session"); + expect(instructions).toContain("proceed on your best judgement"); expect(instructions).not.toContain("You have no shell"); } let git = Bun.spawn(["git", "-C", path, "remote", "set-url", "origin", "workgit:other/repo"], { @@ -202,7 +202,7 @@ describe("Planner workspaces", () => { expect(instructions).toContain(`${cwd}, is a scratch directory Chopin created`); expect(instructions).toContain("holds no repository files"); expect(instructions).toContain("`read_repository_file`"); - expect(instructions).toContain("This is a full Atomic session"); + expect(instructions).toContain("proceed on your best judgement"); } await removeWorkspaces(); expect(await stat(first.registered!.cwd).catch(() => undefined)).toBeUndefined(); @@ -214,7 +214,7 @@ describe("Planner workspaces", () => { let { registered, instructions } = await open("channel", harness); expect(registered).toBeUndefined(); expect(instructions).toContain("You have no shell"); - expect(instructions).not.toContain("full Atomic session"); + expect(instructions).not.toContain("proceed on your best judgement"); } }); }); From cbd7881ac3ffdb7e34263ecf41d67abf909db2f9 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Wed, 30 Sep 2026 01:34:22 -0700 Subject: [PATCH 15/17] Format the Planner's workspace instructions Apply dprint's formatting to the Planner workspace instructions, which the format check flagged. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun run ci passed: dprint check clean after formatting Co-authored-by: Alex Lavaee --- apps/server/src/agent/planner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index 5373f16a..bb2aba7d 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -252,7 +252,8 @@ repository tools read.` : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created empty for this document. It is not a checkout and holds no repository files, so read ${repository} through the repository tools.`; - let questions = `\`ask_user_question\` and \`workflow\` questions appear to the document's members as + let questions = + `\`ask_user_question\` and \`workflow\` questions appear to the document's members as Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`; return [PROMPT, reading, place, questions, bootstrap].filter(Boolean).join("\n\n"); } From 7177f3fe156ecf3e04297019b279e07cd4442dae Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 08:23:17 -0700 Subject: [PATCH 16/17] Answer Atomic host input through added options in the redesigned Decisions Decisions on main no longer offers a free-text custom answer: a member writes one by adding an option. The host input bridge now returns an added option as a written answer, as custom where Atomic's own dialog accepts typed text and as chat otherwise, so Atomic never receives a label it did not offer. An input or editor dialog, which has no options, is answered by the option a member adds. The test fixture adds options the way the client does, and the view tests check that a no-option host card offers "Add an option" rather than a text box. The rebase added the expired-card view to question-view.tsx and the editor questionnaire widget. Their reviewed dynamic class and pointing expressions are unchanged and take no data from the new code, so both review hashes are renewed. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: 1882 pass, 2 PostgreSQL skips, 0 fail, including added options returned as custom, chat, and input/editor answers Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record, Impeccable (no new findings) User-preference: Rebase PRs onto the latest main and adapt to its code rather than keeping the old design User-preference: Fit agent questions to Decisions' options format rather than adding a free-text field Co-authored-by: Alex Lavaee --- .../src/harness/atomic/human-input.test.ts | 25 ++++++++++++--- apps/server/src/harness/atomic/human-input.ts | 17 ++++++---- apps/server/src/testing/decisions.ts | 32 +++++++++++++++++-- .../question/src/react/question-view.test.ts | 30 +++-------------- .../exceptions/dynamic-editor.json | 2 +- .../exceptions/dynamic-packages.json | 2 +- 6 files changed, 68 insertions(+), 40 deletions(-) diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index 226d8dd5..e4991723 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -150,7 +150,7 @@ test("maps confirm/select dialogs without treating custom text as approval or a expect(await custom).toBeUndefined(); }); -test("input and editor are editable free-text cards that round-trip through the document", async () => { +test("input and editor are cards with no options, answered by an option a member adds", async () => { let f = await fixture(); for (let method of ["input", "editor"] as const) { let response = f.input[method]("Title", " initial or hint\n", f.options); @@ -163,13 +163,30 @@ test("input and editor are editable free-text cards that round-trip through the expect(Room.project(roundTrip)).toContain( `header="${method === "input" ? "Input" : "Editor"}"`, ); - expect(Room.project(roundTrip)).not.toContain(""))).not.toContain(" { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [await f.addOption(cards[0]!.id, "Neither")]); + await f.answer(cards[1]!.id, [0, await f.addOption(cards[1]!.id, "G")]); + await f.answer(cards[2]!.id, [await f.addOption(cards[2]!.id, "Something else")]); + expect((await response).answers).toEqual([ + { questionIndex: 0, question: " Which? ", kind: "chat", answer: "Neither" }, + { questionIndex: 1, question: "Select several", kind: "chat", answer: "C, G" }, + { questionIndex: 2, question: "Or free text?", kind: "custom", answer: "Something else" }, + ]); +}); + test("member cancellation retains answered questions and marks the batch cancelled", async () => { let f = await fixture(); let response = f.input.questionnaire(params, f.options); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index 23de5d99..c04da607 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -60,14 +60,19 @@ export function createHumanInput( let answer = outcome.answers[0]!; let question = params.questions[questionIndex]!; let identity = { questionIndex, question: question.question }; - if (answer.custom !== undefined) { - // Atomic accepts `custom` only where its own dialog offers a typed row. - let typed = !question.multiSelect && !question.options.some(option => option.preview); - answers.push({ ...identity, kind: typed ? "custom" : "chat", answer: answer.custom }); + // Atomic accepts `custom` only where its own dialog offers a typed row. + let typed = !question.multiSelect && !question.options.some(option => option.preview); + let offered = new Set(question.options.map(option => option.label)); + let choices = answer.choices ?? []; + // A member writes an answer by adding an option, which Atomic never offered. + let written = answer.custom + || (choices.some(label => !offered.has(label)) ? choices.join(", ") : undefined); + if (written !== undefined) { + answers.push({ ...identity, kind: typed ? "custom" : "chat", answer: written }); } else if (question.multiSelect) { - answers.push({ ...identity, kind: "multi", answer: null, selected: answer.choices ?? [] }); + answers.push({ ...identity, kind: "multi", answer: null, selected: choices }); } else { - let label = answer.choices![0]!; + let label = choices[0]!; let preview = question.options.find(option => option.label === label)?.preview; answers.push({ ...identity, diff --git a/apps/server/src/testing/decisions.ts b/apps/server/src/testing/decisions.ts index ba0879a5..c8fe6082 100644 --- a/apps/server/src/testing/decisions.ts +++ b/apps/server/src/testing/decisions.ts @@ -75,6 +75,21 @@ export async function hostInputRoom(expiresInMs?: number) { throw new Error(frame.message ?? frame.reason); } } + /** A member adds an option to the card's first question, the way Decisions offers written answers. */ + async function addOption(id: string, label: string) { + let question = Store.get(plan.questions, id)!.definition.questions[0]!; + await Questions.addOption(plan, server, channel.id, ws, { + kind: "question:option", + rid: "option", + ts: 0, + id, + question: question.id, + key: crypto.randomUUID(), + label, + }); + refused("option"); + return Store.get(plan.questions, id)!.definition.questions[0]!.options.length - 1; + } async function answer(id: string, value: number[] | string) { let opened = Store.snapshot(plan.questions, id); if (!opened.open) throw new Error("question closed"); @@ -85,10 +100,22 @@ export async function hostInputRoom(expiresInMs?: number) { model.api.val([question.id, "mode"]).set("custom"); if (value) model.api.str([question.id, "custom"]).ins(0, value); } else if (question.multiple) { + model.api.val([question.id, "mode"]).set("choices"); + let held = + (model.view() as Record }>)[question.id] + ?.options ?? {}; for (let index of value) { - model.api.val([question.id, "options", question.options[index]!.id]).set(true); + let option = question.options[index]!.id; + // An option appended after the draft began has no register yet, as in the client. + if (Object.hasOwn(held, option)) model.api.val([question.id, "options", option]).set(true); + else {model.api.obj([question.id, "options"]).set({ + [option]: Question.crdt.schema.val(Question.crdt.schema.con(true)), + });} } - } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + } else { + model.api.val([question.id, "mode"]).set("choices"); + model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + } let patch = model.api.flush(); if (patch) { await Questions.edit(plan, ws, { @@ -120,6 +147,7 @@ export async function hostInputRoom(expiresInMs?: number) { options, cards, answer, + addOption, ws, server, room, diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index 6bd6c704..36f9574f 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -227,7 +227,7 @@ test("Next waits for an answer to the current question, but not in a read-only v )).not.toContain("disabled"); }); -test("a free-text host dialog renders an editable textarea without inventing a choice", () => { +test("a host dialog with no options is answered by adding one, without a free-text box", () => { let definition = normalize({ questions: [{ header: "Input", question: "Write the brief", options: [], multiple: false }], }, { verbatim: true }); @@ -235,33 +235,11 @@ test("a free-text host dialog renders an editable textarea without inventing a c definition, drafts: read(create(definition), definition), onChange() {}, + onAddOption: async () => ({ ok: true as const }), })); - expect(markup).toContain(" { - let question = { header: "Input", question: "Write the brief", multiple: false }; - for ( - let [definition, cap] of [ - [normalize({ questions: [{ ...question, options: [] }] }, { verbatim: true }), false], - [ - normalize({ questions: [{ ...question, options: [{ label: "A", description: "" }] }] }), - true, - ], - ] as const - ) { - let drafts = read(create(definition), definition); - drafts.q0!.mode = "custom"; - let markup = renderToStaticMarkup(createElement(QuestionView, { - definition, - drafts, - onChange() {}, - })); - expect(markup).toContain(" { diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index c8dfd085..126159ce 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -424,7 +424,7 @@ 1 ] ], - "sourceHash": "57762a66f2046b26f1c87547bf45bb21e73eb1c37deda3dcb376ba7056f10b10" + "sourceHash": "cc142fe31440bda91979142fbbedad1de0eeef6d6748b2a1028f08823f276337" }, { "file": "packages/editor/src/widgets/code-view.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 491286a0..a3c954af 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -32,7 +32,7 @@ 1 ] ], - "sourceHash": "8f550f655abbcd6c5264cc69a6777c267ef145d1cc6dd62ec465ba7778b03f21" + "sourceHash": "d52dc462e861603f9a77b4a628044f9e2efd7007e75ca96638b0d9fba9e9d1e1" }, { "file": "packages/icons/src/icon.tsx", From fb57aa9e47c05f858d3c33007bcd24dba66f584b Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 08:41:03 -0700 Subject: [PATCH 17/17] Refuse an answer that would outgrow the document, and show option previews Edits under the per-edit limit could build an answer large enough to push the saved document past its size limit, after which the Planner could no longer edit it. Project the answer onto a copy and validate it first; when it does not fit, the document stays unchanged and the question stays open. Decisions has no preview pane, so an Atomic option's preview (a mockup or code) now travels in its description, and people see it before choosing without the label or the returned answer changing. Both reported by Maggie Appleton in review. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: fail-before/pass-after passed: with about 100 KB of prose, four 50 KB edits built an answer that saved a 302 KB document past the 256 KiB limit; with the check the submit is refused, the document is unchanged and the question stays open Assistant-verification: bun test passed: 1883 pass, 2 PostgreSQL skips, 0 fail Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record, Impeccable (no new findings) Co-authored-by: Alex Lavaee --- .../src/harness/atomic/human-input.test.ts | 58 ++++++++++++++++++- apps/server/src/harness/atomic/human-input.ts | 6 +- apps/server/src/questions/service.ts | 9 +++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index e4991723..ddd0f666 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test"; import { $nodesOfType } from "lexical"; import { limits, QuestionnaireNode, unanswered } from "@chopin/dialect"; import { limits as questionLimits } from "@chopin/question"; +import * as QuestionModel from "@chopin/question"; import * as Questions from "../../questions/service"; import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; @@ -68,7 +69,11 @@ test("HostInput appends one ordered questionnaire batch and returns typed answer ]); expect(cards[0]!.definition.questions[0].options[0]).toMatchObject({ label: " A ", - description: " first ", + description: " first \n\npreview A", + }); + expect(cards[0]!.definition.questions[0].options[1]).toMatchObject({ + label: "B", + description: "second", }); let source = Plan.source(f.plan); expect(source.indexOf("Last paragraph.")).toBeLessThan(source.indexOf(" { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Plan\n\n" + ("Prose paragraph. ".repeat(60) + "\n\n").repeat(100), + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let response = f.input.questionnaire({ + questions: [{ header: "Input", question: "Write the brief", options: [] }], + }, f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]!; + // Each edit stays under the per-edit limit; together they outgrow the document. + for (let chunk = 0; chunk < 4; chunk++) { + let opened = Store.snapshot(f.plan.questions, card!.id); + if (!opened.open) throw new Error("question closed"); + let model = QuestionModel.restore( + opened.model, + Store.get(f.plan.questions, card!.id)!.definition, + ); + if (chunk === 0) model.api.val([question.id, "mode"]).set("custom"); + let text = model.api.str([question.id, "custom"]); + text.ins(text.length(), "x".repeat(50_000)); + await Questions.edit(f.plan, f.ws, { + kind: "question:edit", + rid: `edit-${chunk}`, + ts: 0, + id: card!.id, + patch: [...model.api.flush()!.toBinary()], + }); + } + let before = Plan.source(f.plan); + let current = Store.snapshot(f.plan.questions, card!.id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id: card!.id, + revision: current.revision, + }); + expect(Plan.source(f.plan)).toBe(before); + expect(new TextEncoder().encode(Plan.source(f.plan)).length).toBeLessThanOrEqual( + limits.MAX_SOURCE_BYTES, + ); + expect(Store.outstanding(f.plan.questions).map(open => open.id)).toEqual([card!.id]); + f.controller.abort(); + expect((await response).cancelled).toBe(true); +}); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index c04da607..f64ed743 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -36,7 +36,11 @@ export function createHumanInput( question: workflow ? `${question.question}\n\n${workflow}` : question.question, options: question.options.map(option => ({ label: option.label, - description: option.description, + // Decisions shows no preview pane, so the mockup or code a member + // needs to see before choosing travels in the description. + description: [option.description, option.preview] + .filter(value => value !== undefined) + .join("\n\n"), })), multiple: question.multiSelect ?? false, })), diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index a50bf5ef..50dae63b 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -331,6 +331,15 @@ export async function submit( await Service.exclusive(plan, async () => { let mutation: room.Mutation | undefined; try { + // A long answer can push the document past its size limit, after which the + // Planner can no longer edit it; check on a copy so a refusal changes nothing. + let preview = await room.create(room.project(plan.document)); + try { + room.projectAnswer(preview, msg.id, answers, settled); + room.validate(room.project(preview)); + } finally { + preview.doc.destroy(); + } mutation = room.projectAnswer(plan.document, msg.id, answers, settled); } catch (err) { mutationError = err;