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/AGENTS.md b/AGENTS.md index 00a1f940..ca4c4e73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,8 +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.** The hosted runtime has no shell, - checkout, host filesystem, skills, plugins, or arbitrary GitHub 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 @@ -130,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. @@ -398,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 f1542666..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 @@ -72,14 +75,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/agent/planner.ts b/apps/server/src/agent/planner.ts index b0702b44..bb2aba7d 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"]; @@ -230,13 +231,29 @@ 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, + workspace?: PlannerWorkspace, +): string { + 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 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/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..3de98e93 100644 --- a/apps/server/src/auth/session.ts +++ b/apps/server/src/auth/session.ts @@ -267,6 +267,16 @@ export class Sessions { : undefined; } + /** 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; + 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..a20c02a5 --- /dev/null +++ b/apps/server/src/chat/invoke.test.ts @@ -0,0 +1,411 @@ +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 { Socket, SocketData } from "../wire"; + +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: "" }; + 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, grant("browser-token")); + 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, + claimantSessionId: login.id, + activeOwner: () => owners.resolve(channel.id), + persist: () => Plan.persist(plan), + }; + let closed = false; + let fixture = { + context, + events, + user, + 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 { context, events, user } = await setup(); + 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: string[] = []; + context.openPlannerSession = async () => ({ + ok: true, + value: { + async stream(prompt) { + seen.push(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).toEqual(["@ana: @chopin Plan this.\n"]); + finished.resolve(); + await context.chat.running; + expect(context.chat.busy).toBe(false); + } finally { + saved.resolve(); + finished.resolve(); + } +}); + +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 prompts: string[] = []; + context.openPlannerSession = async () => ({ + ok: true, + value: { + async stream(prompt) { + prompts.push(prompt); + opened.resolve(); + return { + fullStream: (async function*() { + await first.promise; + yield { type: "finish" }; + })(), + } as never; + }, + destroy: async () => {}, + }, + }); + try { + await Chat.invoke(context, user, "First"); + await opened.promise; + 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); + first.resolve(); + await context.chat.running; + 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); + 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(); + } +}); + +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, 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("proceed on your best judgement")).toBe(full); + } + } +}); 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 8a9cc96b..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"; @@ -30,6 +32,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 +49,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 +84,9 @@ type Waiting = Wire.Waiting & { sessionId?: string; /** Verified member identity, retained only for a queued composer message. */ userId?: string; + posted?: boolean; + /** MCP caller whose instruction this is; it never claims ownership without their login. */ + invokedBy?: string; }; export type ActiveMemberRequest = { @@ -89,7 +94,7 @@ export type ActiveMemberRequest = { userId: string; handle: string; text: string; - claimantSessionId: string; + claimantSessionId: string | undefined; turnId: string; lifecycle: number; }; @@ -401,11 +406,17 @@ 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"]; + invokedBy?: string; ownerAvailable?: () => Promise; jobs?: JobService; references?: ReferenceService; @@ -439,6 +450,89 @@ export function send(context: Room, ws: Socket, msg: Request): Promis return completed; } +/** + * 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, + 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 { + await resolveOwner(context.auth, context.repository, room, context.claimantSessionId); + } 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; + if (verified) rememberCheckout(room, verified); + 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, + 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; @@ -697,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 ( @@ -840,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 }> { @@ -893,11 +987,14 @@ async function repositorySession( let result = await open(binding, { room: documentRoom(context), repository, - instructions: plannerInstructions( - `${repository.owner}/${repository.name}`, - bootstrap, - ), + instructions: workspace => + plannerInstructions( + `${repository.owner}/${repository.name}`, + bootstrap, + workspace, + ), model: context.config.model, + harness: context.config.harness, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); opened = result.value; @@ -928,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."); @@ -981,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[] = [], @@ -1027,7 +1128,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, @@ -1090,13 +1191,13 @@ 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, 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, @@ -1114,7 +1215,7 @@ function startRun( handle: string, text: string, thread: string | undefined, - claimantSessionId: string, + claimantSessionId: string | undefined, reserved = false, member?: MemberRequest, references?: Wire.Reference[], @@ -1138,7 +1239,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/config.test.ts b/apps/server/src/config.test.ts index 23e66c49..614aebb6 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,46 +1,7 @@ import { describe, expect, it } from "bun:test"; -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, - 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", () => { @@ -113,6 +74,40 @@ 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("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)", + ); + } + 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"); + } + } + }); + 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..4e3ef036 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 { @@ -130,6 +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.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 new file mode 100644 index 00000000..1e3509d4 --- /dev/null +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -0,0 +1,512 @@ +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); + +/** + * 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; + 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 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"); + 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("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(); + 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 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 }); + 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 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 })) { + 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"), + join(home, ".agents"), + 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..8e35994e --- /dev/null +++ b/apps/server/src/harness/atomic/adapter.test.ts @@ -0,0 +1,183 @@ +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: (systemPrompt?: string) => + handlers.get("before_agent_start")!({ type: "before_agent_start", systemPrompt }), + 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 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." }); + 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, 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 }); + 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("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); + 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 worker 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..81f1f31f --- /dev/null +++ b/apps/server/src/harness/atomic/adapter.ts @@ -0,0 +1,674 @@ +/** + * Chopin's Atomic `HarnessV1` adapter. + * + * 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"; +import { + AuthStorage, + createAgentSession, + DefaultResourceLoader, + FileAuthStorageBackend, + getAgentConfigPaths, + getAgentDir, + ModelRuntime, + SessionManager, + SettingsManager, +} from "@bastani/atomic"; +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, + 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, +}; + +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, + 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 }; + full?: boolean; +}; + +/** + * 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", 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.` }; + }); + }; +} + +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 full = fullPlanner(startOptions.sessionId); + let sessionManager: SessionManager | undefined; + let settingsManager = SettingsManager.inMemory(SETTINGS); + let policy: TurnPolicy = { + full: !!full, + 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 (!full && !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) { + if (!full) directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); + let cwd = full?.cwd ?? directory!; + let agentDir = full ? getAgentDir() : directory!; + if (full) settingsManager = await fullSettings(agentDir, cwd); + sessionManager ??= SessionManager.inMemory(cwd); + let loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + ...(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, + agentDir, + modelRuntime: models, + model, + fallbackModels: [], + sessionManager, + settingsManager, + resourceLoader: loader, + 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 = full + ? undefined + : hostLeak(session, created.extensionsResult.extensions.length, hostNames); + if (leak || closed) { + 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."); + } + 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 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( + `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: !!full }); + } + 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/atomic/checkout.test.ts b/apps/server/src/harness/atomic/checkout.test.ts new file mode 100644 index 00000000..22b788f2 --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.test.ts @@ -0,0 +1,37 @@ +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, and nothing else", async () => { + 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); + expect(await verifiedCheckout(repository, path)).toBe(await realpath(path)); + } + let mismatch = await checkout("mismatch", "https://github.com/other/score.git"); + 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 new file mode 100644 index 00000000..0a6f710b --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.ts @@ -0,0 +1,32 @@ +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 }, + candidate: string, +): Promise { + 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; + } +} 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..dd31ba08 --- /dev/null +++ b/apps/server/src/harness/atomic/full.test.ts @@ -0,0 +1,308 @@ +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 { hostInputRoom } from "../../testing/decisions"; +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 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( + registered: boolean, + prompt = "plain", + { 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-")); + let agentDir = join(root, "agent"); + let cwd = join(root, checkout ? "checkout" : "empty"); + let previous = process.env.ATOMIC_CODING_AGENT_DIR; + let received: QuestionParams[] = []; + let harness = createAtomicAdapter({ + auth: "ai-gateway", + 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:{}}; }});`, + ); + if (checkout) { + let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { + stdout: "pipe", + stderr: "pipe", + }); + 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, + 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; + let requests = [...stub.requests]; + 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; + 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(); + } + } 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 }); + } +} + +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 FULL_TOOLS) 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("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, "question"); + expect(question.received).toEqual([params]); + expect(question.requests.at(-1)!.toolResults.join("\n")).toContain("Second"); + 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("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 { + for (let prompt of ["multiple", "preview"]) { + 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"); + expect(output).toContain(` typed ${prompt}\n`); + expect(output).not.toContain("InvalidHostInput"); + } + } finally { + await room.close(); + } +}); + +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 new file mode 100644 index 00000000..fc79c5a5 --- /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(); + +/** 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); + 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..ddd0f666 --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -0,0 +1,556 @@ +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"; +import * as Room from "../../plan/room"; +import * as Edit from "../../plan/edit"; +import { hostInputRoom } from "../../testing/decisions"; +import type { QuestionParams } from "@bastani/atomic"; + +let cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function fixture(expiresInMs?: number) { + let f = await hostInputRoom(expiresInMs); + cleanups.push(f.close); + return f; +} + +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 \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 { 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) { + 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 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); + 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"}"`, + ); + let projected = Room.project(roundTrip); + let own = projected.slice(projected.indexOf(`"))).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); + 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("expiry keeps every unanswered card in Decisions, marked expired, and answers no HostInput method", async () => { + let f = await fixture(30); + for ( + 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]> + ) { + expect(await invoke()).toEqual(expected); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + } + 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 () => { + 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(); +}); + +/** Re-import the canonical document, proving the dialect accepts every card it holds. */ +async function documentCards(source: string) { + let document = await Room.create(source); + try { + return document.editor.getEditorState().read(() => + $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); + 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); +}); + +test("an answer that would make the document too large leaves it unchanged and the question open", async () => { + 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 new file mode 100644 index 00000000..f64ed743 --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.ts @@ -0,0 +1,137 @@ +import { limits } from "@chopin/question"; + +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. + * + * 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, + ): 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, + // 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, + })), + }, { verbatim: true }); + 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 }; + } + 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 }; + // 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: choices }); + } else { + let label = 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/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.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..44228f29 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -1,7 +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"; const credentials = new Map string | undefined; @@ -14,9 +17,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 +52,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 +69,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"); @@ -90,4 +113,5 @@ export async function shutdownHarnesses(): Promise { credentials.clear(); await selected?.shutdown(); selected = undefined; + await removeWorkspaces(); } 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..f9573744 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "bun:test"; +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"; import type { PlannerSessionDependencies } from "./session"; @@ -111,3 +117,104 @@ describe("openPlannerSession", () => { expect(destroyed).toEqual({ sandbox: 1, session: 0, unregistered: 1 }); }); }); + +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("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"], { + 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("proceed on your best judgement"); + } + 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("proceed on your best judgement"); + } + }); +}); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index a22664a3..d02751a9 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -2,6 +2,9 @@ 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 { 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"; @@ -18,11 +21,13 @@ type Sandbox = Awaited>; type PlannerAgent = typeof plannerAgent; -type PlannerChannel = { +export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string; + instructions: string | ((workspace?: PlannerWorkspace) => string); model?: string; + /** `atomic` runs the session as a full Atomic session in the channel's workspace. */ + harness?: string; }; export type PlannerSession = { @@ -67,12 +72,25 @@ 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 workspace = channel.harness === "atomic" + ? await plannerWorkspace(channel.room.id, channel.repository) + : undefined; + if (workspace) { + unregisterFull = registerFullPlanner(sessionId, { + cwd: workspace.cwd, + humanInput: createHumanInput(channel.room), + }); + } + let instructions = typeof channel.instructions === "function" + ? channel.instructions(workspace) + : channel.instructions; let agent = deps.agent ?? plannerAgent; let opening = agent.createSession({ sessionId, sandboxSession: sandbox }); let deadline = new Promise((_, reject) => { @@ -101,6 +119,7 @@ export async function openPlannerSession( abortSignal, options: { ...channel, + instructions, owner, githubTools: tools.value, }, @@ -114,6 +133,7 @@ export async function openPlannerSession( await sandbox.destroy(); } finally { release(); + unregisterFull?.(); } } })(), @@ -131,6 +151,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/main.ts b/apps/server/src/main.ts index 7212ab18..bf4b4c77 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,31 @@ registerMcpRoutes(router, hostedAuth, { return heldLease; }, }, { + 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(() => {}); + } + 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 e92f569a..d0ec91a9 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; }; @@ -402,6 +424,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome([ @@ -430,6 +453,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ARCHIVED_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), @@ -453,12 +477,66 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), ], }, }, + { + name: "invoke_planner", + 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: { + 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, 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"], + 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: @@ -660,6 +738,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 => @@ -730,6 +809,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) @@ -914,6 +994,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 ba51ab99..19ad96a4 100644 --- a/apps/server/src/mcp/documents.test.ts +++ b/apps/server/src/mcp/documents.test.ts @@ -50,7 +50,38 @@ 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 }, + invoke: { invoke: async () => ({ kind: "refused", code: "planner-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(); diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 6c771cca..236e9df0 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -5,6 +5,7 @@ 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"; @@ -24,6 +25,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 +206,146 @@ async function plan(context: ReturnType) { } describe("the hosted MCP adapter", () => { + 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; + }, + }); + 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 URL invocation's checkout through unverified for the harness to judge", async () => { + let context = setup(); + context.github.repositoryValue.permissions.admin = true; + let opened = await plan(context); + 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 input = { + id: "https://chopin.test/documents/octo-org/score/release-readiness", + instruction: "Review", + checkout: "/does-not-exist", + }; + 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: "/does-not-exist", + instruction: input.instruction, + repository: { id: "R_score", owner: "octo-org", name: "score" }, + }); + } finally { + await Service.close(opened.plan); + } + }); + + 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, + }; + 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..29b97940 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -12,10 +12,13 @@ import { implementationLifecycle } from "../tasks/lifecycle"; 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 +33,17 @@ export type HostedCaller = { user: GitHubUser; }; +export type PlannerInvocation = { + channel: ChannelRecord; + repository: HostedRepository; + 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; +}; + export type ImplementationPersistence = { lease(): Lease }; export type HostedCallbacks = { archiveChannel?: (channelId: string, now: Date) => Promise; @@ -38,6 +52,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 +270,47 @@ export function hosted( } return { + invoke: 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 code = await callbacks.invokePlanner!({ + channel, + repository, + user: caller.user, + session: await auth.sessions.forUser(caller.user.id), + instruction: input.instruction, + ...(input.checkout === undefined ? {} : { checkout: input.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/plan/room.ts b/apps/server/src/plan/room.ts index c00773f1..70c42ce7 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 }]); @@ -532,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 f007c2ce..50dae63b 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"; @@ -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, @@ -67,7 +67,7 @@ export function identify(raw: unknown): Definition { 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, @@ -103,7 +108,10 @@ export async function ask( definition: Definition, placement?: AskPlacement, created?: () => void, + signal?: AbortSignal, + expiresInMs?: number, ): 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"); @@ -117,31 +125,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( @@ -165,7 +182,32 @@ export async function ask( created?.(); }); - return Promise.all(asked.map(item => item.waiting)); + let failed = Promise.withResolvers(); + 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 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 closing; + return ended; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + } } /** Validate every placement before registering records or document nodes. */ @@ -289,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; @@ -525,57 +576,87 @@ 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 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, status); + if (!claimed.ok) return claimed; let mutation: room.Mutation | undefined; try { - mutation = room.removeQuestionnaire(plan.document, msg.id); + mutation = project(); } catch (err) { - mutationError = err; - return; + 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(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, ...closed }); + 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", - ts: 0, - id: msg.id, - ok: false, - reason: "resolving", - }); - } - 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, + finish(); + 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.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..85424b20 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; + /** 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/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 new file mode 100644 index 00000000..c8fe6082 --- /dev/null +++ b/apps/server/src/testing/decisions.ts @@ -0,0 +1,156 @@ +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(expiresInMs?: 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, 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) { + 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); + } + } + /** 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"); + 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) { + model.api.val([question.id, "mode"]).set("choices"); + let held = + (model.view() as Record }>)[question.id] + ?.options ?? {}; + for (let index of value) { + 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, "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, { + 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, + addOption, + ws, + server, + room, + close: () => Plan.close(plan), + }; +} 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/bun.lock b/bun.lock index 669bf6c7..c83d381c 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.25", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", @@ -380,6 +382,26 @@ "@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.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.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.25", "", { "os": "darwin", "cpu": "arm64" }, "sha512-puuAEWVuQzpttskalJ3/LWb4gBp8yrWK+CFaSm7kDyJ7Y9XOmG90+xDpFXfT8uw9FkpsD9Ucjnh9/pBuBFzq+A=="], + + "@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.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZGj95QTWjp14qtXyeL+qO5pF4LA7E+n7noacBOrQ9ryjc92YMCr5Z1e6NRm9Gmxc1uvb56LkxOGSgblR2jMUfA=="], + + "@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.25", "", { "os": "linux", "cpu": "x64" }, "sha512-cCkTXcQAAv55w8f0ffOZ3A5p18vLu8b6YqjOUhrnNerrBzp+E96QXLQX3dyXqEf/4FVgzLz06DRXCTSZrQ1hXA=="], + + "@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.25", "", { "os": "win32", "cpu": "arm64" }, "sha512-SMTJP0zwi/wKVIqRYvEoQX2hHEZ4V8bLJIhaiqsGn6yL7JHl9bw+uGqQ4krLbOZ+8+fPI4zi0ub6N7l+5zOp9Q=="], + + "@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=="], "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], @@ -474,6 +496,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 +518,37 @@ "@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.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.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-telemetry": ["@earendil-works/pi-telemetry@0.85.1", "", {}, "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw=="], + "@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.99.2", "", {}, "sha512-WThYU4XM6jjjzH4FzLdreN7QAPS9SDdokL0W0Ldheg1ssCIJkfpK7PgebFm7/PoQub7OiXx+SRF6B/oF7z7lXA=="], + + "@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=="], - "@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.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=="], + + "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=="], @@ -1946,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=="], @@ -1972,7 +2128,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 +2166,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 +2178,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 +2230,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 +2248,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 +2266,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 +2308,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 +2344,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 +2394,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 +2418,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 +2430,81 @@ "@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/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=="], - "@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.99.2", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-2dJWiNsOJm/ioBl1fhpJmw0hIjFUhM0CAteEnVkiVG0Kg4LimR7Asq8PfjclEvrbCPsDntzy7rGKhTZObI9TZw=="], + + "@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=="], + "@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.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=="], + "@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.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=="], + + "@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 +2518,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 +2534,56 @@ "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=="], + + "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=="], - "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=="], + + "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/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "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 +2592,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 +2672,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/@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/@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/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-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/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/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@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..44519291 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] ``` @@ -118,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). @@ -473,6 +477,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/authentication.md b/docs/authentication.md index ec24bed5..21b99180 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -202,6 +202,11 @@ 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. +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 `GITHUB_ALLOWED_USERS` and `GITHUB_ALLOWED_ORGANIZATIONS` are optional, @@ -254,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 89f7e8b0..e69e329c 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. It does not implement -code or change GitHub. +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; @@ -12,21 +13,27 @@ 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. 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 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 @@ -35,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, @@ -43,16 +56,16 @@ ownership generation. ## Runtime isolation -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 Planner has no: +That isolated Planner has no: - checkout, shell, or host filesystem; - skills, plugins, or configuration discovery; @@ -64,7 +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). -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); @@ -83,15 +96,120 @@ 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. +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. 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. +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`, +extension dialogs, and workflow-stage input become ordinary shared Decisions: + +- 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. + 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`. 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. +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. + +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 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; @@ -103,6 +221,9 @@ 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 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 @@ -213,6 +334,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/local-agent-mcp.md b/docs/local-agent-mcp.md index ee3d63e3..97296170 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 `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" @@ -52,7 +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. + 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 @@ -69,6 +72,75 @@ 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 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: + +- 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. + +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: + +```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. + +`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 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`: 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 + their ordinary document/access meanings. + ## Document URLs and IDs The `url` returned by `create_document` is the readable canonical route: @@ -78,8 +150,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 `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 +294,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 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 [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 11ca1e07..4bcdde7d 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,85 @@ 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.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. + +**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. + +- `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, + 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 + 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. 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. 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. +- 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 @@ -121,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. @@ -131,7 +249,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 +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`. | -| `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. `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. @@ -365,7 +483,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..04b4633f 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.25", "@earendil-works/pi-coding-agent": "0.85.1", "ai": "7.0.116", "typebox": "1.3.7", 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 80bd73f6..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` @@ -290,6 +303,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..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 }, }, @@ -110,8 +114,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 +126,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 +141,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..6a82a68a 100644 --- a/packages/dialect/src/index.ts +++ b/packages/dialect/src/index.ts @@ -74,6 +74,8 @@ export { $createQuestionnaireNode, $isQuestionnaireNode, QuestionnaireNode, + toElement as questionnaireElement, + unanswered, } 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/dialect/src/nodes/questionnaire.ts b/packages/dialect/src/nodes/questionnaire.ts index 814b6e6e..60474111 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 { @@ -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,20 +217,31 @@ 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", - 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 +249,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/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 e22e5c9f..5399d8ce 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 = { @@ -181,7 +183,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -201,7 +203,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -268,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/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..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`); } @@ -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/limits.ts b/packages/question/src/limits.ts index 7a3fb939..6c3556bf 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; @@ -26,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/question.test.ts b/packages/question/src/question.test.ts index 9c6d9b97..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"; @@ -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); @@ -54,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 0587907f..36f9574f 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, limits, normalize, read } from "../index"; test("a replacement definition falls back before rendering when its active question disappears", () => { let storage = { @@ -225,3 +226,53 @@ test("Next waits for an answer to the current question, but not in a read-only v "Next", )).not.toContain("disabled"); }); + +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 }); + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: read(create(definition), definition), + onChange() {}, + onAddOption: async () => ({ ok: true as const }), + })); + expect(markup).toContain("Write the brief"); + expect(markup).toContain("Add an option"); + expect(markup).not.toContain(" { + 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" + ? + : }
); } 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); } }); }; diff --git a/packages/question/src/schema.ts b/packages/question/src/schema.ts index b19d4fe7..98994af5 100644 --- a/packages/question/src/schema.ts +++ b/packages/question/src/schema.ts @@ -45,8 +45,15 @@ function exact(value: Record, 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`); + if (verbatim) return value; let result = value.trim(); if (!optional && !result) fail(`${name} is required`); if (result.length > max) fail(`${name} exceeds ${max} characters`); @@ -60,16 +67,20 @@ function text(value: unknown, name: string, max: number, optional = false): stri * 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): Definition { +export function normalize(raw: unknown, { verbatim = false } = {}): Definition { let args = record(raw, "Question tool input"); exact(args, ["questions"], "Question tool input"); 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`); } @@ -77,10 +88,10 @@ 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) { + 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") { @@ -96,12 +107,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 +125,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 } : {}), }; }); 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. 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",