diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f8d8cb8..703568f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: contents: read steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: "24" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a4fa2c3..5ff27bab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: contents: read steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '24' - name: Configure sandboxing @@ -41,7 +41,7 @@ jobs: id-token: write steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '24' registry-url: 'https://registry.npmjs.org' @@ -51,6 +51,7 @@ jobs: create-release: needs: publish-to-npm runs-on: ubuntu-latest + environment: release permissions: {} steps: - name: Generate GitHub token @@ -61,7 +62,32 @@ jobs: private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - name: Create Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true + + trigger-registry-update: + needs: create-release + runs-on: ubuntu-latest + environment: release + permissions: {} + steps: + - name: Generate token scoped to the registry repo + uses: actions/create-github-app-token@v3 + id: registry-token + with: + app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }} + private-key: ${{ secrets.REGISTRY_UPDATER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: registry + + - name: Dispatch registry version update + env: + GH_TOKEN: ${{ steps.registry-token.outputs.token }} + run: | + gh workflow run update-versions.yml \ + --repo ${{ github.repository_owner }}/registry \ + --ref main \ + -f apply=true \ + -f agents=codex-acp diff --git a/README.md b/README.md index c8a0b977..5dbe12f0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Model, reasoning effort, fast mode, approval, and sandbox mode configuration. - Text prompts, embedded context, images, resource links, and additional workspace directories. - Shell command, file change, permission request, MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. +- Subagent launches as standard ACP tool calls, with Codex thread identity and activity details in namespaced `_meta.codex.subagent` metadata. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Native ACP session forking through Codex App Server `thread/fork`. - Acknowledged steering of an active Codex turn through app-server `turn/steer`. @@ -49,11 +50,12 @@ The adapter advertises ACP auth methods during initialization. Clients can authe ## Steering extension The initialize response advertises `_meta.codex.steer` with the applied notification -method and active-turn configuration policy. To steer a running prompt, send another -`session/prompt` with a unique `_meta.codex.steer.id`. The adapter calls Codex -`turn/steer` and emits `_codex/steerApplied { sessionId, steerId }` only after Codex -commits the correlated user-message item. The steer keeps the active turn's model, -mode, and configuration; slash commands cannot be steered. +method and active-turn configuration policy. To steer a running prompt, call the +advertised `_session/steering` method with `{ sessionId, prompt }`. Clients that need a +committed-application boundary can also include a unique `steerId`. The adapter calls +Codex `turn/steer` and emits `_codex/steerApplied { sessionId, steerId }` only after +Codex commits that correlated user-message item. The steer keeps the active turn's +model, mode, and configuration; slash commands cannot be steered. ## Runtime options diff --git a/examples/steering.ts b/examples/steering.ts new file mode 100644 index 00000000..f9f34e46 --- /dev/null +++ b/examples/steering.ts @@ -0,0 +1,444 @@ +#!/usr/bin/env tsx + +/** + * Steering demo — mid-turn edition. + * + * The plain `steering.ts` example steers a single-message answer ("count to + * 30"). There the injected prompt can only take effect *after* that message is + * finished: a turn made of one model step has no earlier boundary for Codex to + * inject at, so `turn/steer` appends the message and the model reads it on its + * next step — which is the end. + * + * This example instead gives Codex a genuinely multi-step task: a "treasure + * hunt" where each clue file only reveals the *name of the next clue*. Because + * the reads are sequential and dependent, the agent cannot batch them or read + * ahead — the turn contains several model steps. A steering message injected + * part-way through is therefore picked up *between* steps and visibly changes + * what the agent does next (it stops the hunt early). + * + * Run it with: + * node --import tsx examples/steering.ts + * # or: npm run example:steering:multistep + * + * Auth: uses your existing Codex login in ~/.codex. If you instead export + * CODEX_API_KEY / OPENAI_API_KEY it will authenticate with that. + * + * Knobs (env): + * STEERING_EXAMPLE_MODEL model id (default gpt-5.6-sol) + * STEER_AFTER_TOOL_CALLS inject the steer after N clue reads (default 2) + * NO_COLOR disable ANSI colors + */ + +import * as acp from "@agentclientprotocol/sdk"; +import {type ChildProcess, spawn} from "node:child_process"; +import {fileURLToPath} from "node:url"; +import {mkdtemp, rm, writeFile} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {Readable, Writable} from "node:stream"; + +const STEERING_METHOD = "_session/steering"; +const EXAMPLE_TIMEOUT_MS = 60_000; +const DEFAULT_EXAMPLE_MODEL = "gpt-5.6-sol"; +const exampleModel = process.env["STEERING_EXAMPLE_MODEL"] ?? DEFAULT_EXAMPLE_MODEL; + +const parsedSteerAfter = Number(process.env["STEER_AFTER_TOOL_CALLS"] ?? "2"); +const STEER_AFTER_TOOL_CALLS = Number.isFinite(parsedSteerAfter) && parsedSteerAfter > 0 + ? Math.floor(parsedSteerAfter) + : 2; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// One note per clue file. The hunt is CLUE_NOTES.length steps long. +const CLUE_NOTES = ["compass", "lantern", "map", "brass key", "torch", "rope", "chart", "chest"]; + +const initialPrompt = [ + "You're solving a treasure hunt inside this folder.", + "Start by reading the file `clue-1.txt`.", + "Each clue holds one note to remember and tells you the exact filename of the next clue.", + "Follow the trail, reading EXACTLY ONE clue per step — use a separate read for each file,", + "and do NOT list the folder or read several files at once.", + "Keep going until a clue tells you to STOP, then reply with the full ordered list of notes you collected.", +].join(" "); + +const steeringPrompt = [ + "Change of plan — stop the treasure hunt immediately.", + "Do not open any more clues.", + "Just tell me the notes you've collected so far and which clue number you stopped on.", +].join(" "); + +type SteeringRequest = { + sessionId: acp.SessionId; + prompt: acp.ContentBlock[]; +}; + +type SteeringResponse = { + outcome: "injected" | "startedNewTurn"; +}; + +type ThreadStatusType = "active" | "idle" | "systemError"; +type StateListener = () => void; + +let trackedSessionId: acp.SessionId | null = null; +const toolCallsSeen = new Set(); +let finishedTransitions = 0; +let lastChannel: string | null = null; +const stateListeners = new Set(); + +// --------------------------------------------------------------------------- +// Tiny ANSI helpers (no dependencies). Honors NO_COLOR and non-TTY output. +// --------------------------------------------------------------------------- +const useColor = Boolean(process.stdout.isTTY) && !process.env["NO_COLOR"]; +const paint = (code: string) => (text: string): string => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text); +const c = { + bold: paint("1"), + dim: paint("2"), + red: paint("31"), + green: paint("32"), + yellow: paint("33"), + magenta: paint("35"), + cyan: paint("36"), +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function createAgentEnvironment(): NodeJS.ProcessEnv { + const configString = process.env["CODEX_CONFIG"]; + let config: Record = {}; + if (configString) { + const parsedConfig: unknown = JSON.parse(configString); + if (!isRecord(parsedConfig)) { + throw new Error("CODEX_CONFIG must contain a JSON object"); + } + config = parsedConfig; + } + return { + ...process.env, + CODEX_CONFIG: JSON.stringify({ + ...config, + model: exampleModel, + }), + }; +} + +function supportsSteering(response: acp.InitializeResponse): boolean { + const steering = response._meta?.["steering"]; + return isRecord(steering) && steering["supported"] === true; +} + +function readThreadStatus(update: acp.SessionUpdate): ThreadStatusType | undefined { + if (update.sessionUpdate !== "session_info_update") { + return undefined; + } + const codex = update._meta?.["codex"]; + if (!isRecord(codex)) { + return undefined; + } + const threadStatus = codex["threadStatus"]; + if (!isRecord(threadStatus)) { + return undefined; + } + const type = threadStatus["type"]; + return type === "active" || type === "idle" || type === "systemError" ? type : undefined; +} + +function notifyStateListeners(): void { + for (const listener of stateListeners) { + listener(); + } +} + +// --------------------------------------------------------------------------- +// Streaming output: group consecutive chunks of the same kind under a header +// so thinking / agent text / tool calls stay visually separated. +// --------------------------------------------------------------------------- +function writeChannel(channel: string, label: string, text: string): void { + if (lastChannel !== channel) { + process.stdout.write(`\n${label}\n`); + lastChannel = channel; + } + process.stdout.write(text); +} + +function writeEvent(line: string): void { + process.stdout.write(`\n${line}\n`); + lastChannel = null; +} + +function recordSessionUpdate(params: acp.SessionNotification): void { + if (params.sessionId !== trackedSessionId) { + return; + } + + const update = params.update; + switch (update.sessionUpdate) { + case "agent_message_chunk": + if (update.content.type === "text") { + writeChannel("message", c.bold(c.cyan("🤖 agent")), update.content.text); + } + break; + case "agent_thought_chunk": + if (update.content.type === "text") { + writeChannel("thought", c.dim("💭 thinking"), c.dim(update.content.text)); + } + break; + case "tool_call": { + const isNew = !toolCallsSeen.has(update.toolCallId); + toolCallsSeen.add(update.toolCallId); + writeEvent(c.yellow(`🔧 tool call #${toolCallsSeen.size}: ${update.title} [${update.status}]`)); + if (isNew) { + notifyStateListeners(); + } + break; + } + case "tool_call_update": + if (update.status) { + writeEvent(c.dim(` ↳ ${update.toolCallId} [${update.status}]`)); + } + break; + } + + const threadStatus = readThreadStatus(update); + if (threadStatus === "idle" || threadStatus === "systemError") { + finishedTransitions += 1; + notifyStateListeners(); + } +} + +async function waitForState( + predicate: () => boolean, + description: string, + timeoutMs = EXAMPLE_TIMEOUT_MS, +): Promise { + if (predicate()) { + return; + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + stateListeners.delete(checkState); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeoutMs); + const checkState = (): void => { + if (!predicate()) { + return; + } + clearTimeout(timeout); + stateListeners.delete(checkState); + resolve(); + }; + stateListeners.add(checkState); + }); +} + +async function createTreasureHunt(): Promise<{workspaceDir: string; clueCount: number}> { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "codex-steering-")); + const clueCount = CLUE_NOTES.length; + for (let index = 0; index < clueCount; index += 1) { + const step = index + 1; + const note = CLUE_NOTES[index]; + const isLast = step === clueCount; + const nextInstruction = isLast + ? "This is the final clue. STOP here — do not open any more files. Report every note you collected, in order." + : `When ready, read the next clue in the file named "clue-${step + 1}.txt".`; + const body = + `Treasure hunt — clue ${step} of ${clueCount}\n\n` + + `Note to remember #${step}: ${note}\n\n` + + `${nextInstruction}\n`; + await writeFile(path.join(workspaceDir, `clue-${step}.txt`), body, "utf8"); + } + return {workspaceDir, clueCount}; +} + +function printHeader(workspaceDir: string, clueCount: number): void { + const line = "─".repeat(66); + console.log(c.bold(`\n${line}`)); + console.log(c.bold(" Codex ACP — mid-turn steering demo")); + console.log(line); + console.log(` model : ${c.cyan(exampleModel)}`); + console.log(` workspace : ${c.dim(workspaceDir)}`); + console.log(` clue files : ${clueCount} (clue-1.txt … clue-${clueCount}.txt)`); + console.log(` steer after : ${STEER_AFTER_TOOL_CALLS} tool call(s)`); + console.log(line); + console.log(c.dim(" Task: follow the treasure-hunt chain, one clue at a time.")); + console.log(c.dim(" Mid-turn we inject a steering message telling it to stop early.")); + console.log(`${line}\n`); +} + +function printBanner(text: string): void { + const line = "═".repeat(66); + process.stdout.write(`\n${c.magenta(line)}\n${c.magenta(c.bold(` ${text}`))}\n${c.magenta(line)}\n`); + lastChannel = null; +} + +function printSummary(clueCount: number, cluesAtSteer: number, stopReason: string, steered: boolean): void { + const line = "─".repeat(66); + const stoppedEarly = toolCallsSeen.size < clueCount; + console.log(`\n\n${c.bold(line)}`); + console.log(c.bold(" Summary")); + console.log(line); + console.log(` tool calls total : ${toolCallsSeen.size} of up to ${clueCount} clues`); + console.log(` steered after : ${steered ? `${cluesAtSteer} clue(s)` : "not steered"}`); + console.log(` stop reason : ${stopReason}`); + console.log(line); + if (!steered) { + console.log(c.yellow(" • The turn finished before we could steer. Lower STEER_AFTER_TOOL_CALLS")); + console.log(c.yellow(" or use a slower model to catch the turn while it is still running.")); + } else if (stoppedEarly) { + console.log(c.green(" ✔ The agent stopped BEFORE reading every clue — the steering message")); + console.log(c.green(" was picked up mid-turn and changed its course.")); + } else { + console.log(c.yellow(" • The agent read every clue. Steering still applied, but the turn was")); + console.log(c.yellow(" short — try a longer chain (add CLUE_NOTES) or steer earlier.")); + } + console.log(`${line}\n`); +} + +async function stopAgent(agentProcess: ChildProcess): Promise { + if (agentProcess.stdin && !agentProcess.stdin.destroyed && !agentProcess.stdin.writableEnded) { + agentProcess.stdin.end(); + } + if (agentProcess.exitCode !== null || agentProcess.signalCode !== null) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 2_000); + agentProcess.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + if (agentProcess.exitCode === null && agentProcess.signalCode === null) { + agentProcess.kill(); + } +} + +async function main(): Promise { + const {workspaceDir, clueCount} = await createTreasureHunt(); + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const agentProcess = spawn(npmCommand, ["run", "--silent", "start"], { + cwd: repositoryRoot, + env: createAgentEnvironment(), + stdio: ["pipe", "pipe", "inherit"], + }); + if (!agentProcess.stdin || !agentProcess.stdout) { + throw new Error("Failed to open stdio pipes for the ACP agent"); + } + const stream = acp.ndJsonStream( + Writable.toWeb(agentProcess.stdin), + Readable.toWeb(agentProcess.stdout) as ReadableStream, + ); + + try { + await acp.client({name: "steering-multistep-example"}) + .onRequest(acp.methods.client.session.requestPermission, (ctx) => { + // A real client would prompt the user here. To keep the demo + // hands-free we auto-approve each read once. + const {toolCall, options} = ctx.params; + const allow = options.find((option) => option.kind === "allow_once") ?? options[0]; + if (!allow) { + return {outcome: {outcome: "cancelled"}}; + } + writeEvent(c.green(` ✔ auto-approving: ${toolCall.title ?? toolCall.toolCallId} → "${allow.name}"`)); + return {outcome: {outcome: "selected", optionId: allow.optionId}}; + }) + .onNotification(acp.methods.client.session.update, (ctx) => { + recordSessionUpdate(ctx.params); + }) + .connectWith(stream, async (agent) => { + const initializeResponse = await agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientInfo: { + name: "steering-multistep-example", + version: "1.0.0", + }, + }); + if (!supportsSteering(initializeResponse)) { + throw new Error("The agent did not advertise steering support"); + } + + const apiKey = process.env["CODEX_API_KEY"] ?? process.env["OPENAI_API_KEY"]; + if (apiKey && initializeResponse.authMethods?.some((method) => method.id === "api-key")) { + await agent.request(acp.methods.agent.authenticate, { + methodId: "api-key", + _meta: { + "api-key": {apiKey}, + }, + }); + } + + const session = await agent.request(acp.methods.agent.session.new, { + cwd: workspaceDir, + mcpServers: [], + }); + trackedSessionId = session.sessionId; + + printHeader(workspaceDir, clueCount); + process.stdout.write(c.dim(`📤 prompt → ${initialPrompt}\n`)); + + let promptDone = false; + const promptPromise = agent.request(acp.methods.agent.session.prompt, { + sessionId: trackedSessionId, + prompt: [{type: "text", text: initialPrompt}], + }).finally(() => { + promptDone = true; + notifyStateListeners(); + }); + promptPromise.catch(() => {}); + + // Let the agent work through a couple of clues, then steer mid-turn. + await Promise.race([ + waitForState( + () => toolCallsSeen.size >= STEER_AFTER_TOOL_CALLS || promptDone, + `the agent to open ${STEER_AFTER_TOOL_CALLS} clue(s)`, + ).catch(() => {}), + promptPromise.then(() => undefined, () => undefined), + ]); + + const cluesAtSteer = toolCallsSeen.size; + const turnAlreadyFinished = promptDone || finishedTransitions > 0; + let steered = false; + + if (turnAlreadyFinished) { + writeEvent(c.red("⚠ The turn finished before we could steer — skipping the steering step.")); + } else { + steered = true; + printBanner(`Injecting steering message after ${cluesAtSteer} clue(s)`); + process.stdout.write(`${c.magenta(`✋ steer → ${steeringPrompt}`)}\n`); + lastChannel = null; + + const steeringResponse = await agent.request(STEERING_METHOD, { + sessionId: trackedSessionId, + prompt: [{type: "text", text: steeringPrompt}], + }); + if (steeringResponse.outcome !== "injected" && steeringResponse.outcome !== "startedNewTurn") { + throw new Error(`Unexpected steering response: ${JSON.stringify(steeringResponse)}`); + } + writeEvent(c.magenta(c.bold(` outcome: ${steeringResponse.outcome}`))); + if (steeringResponse.outcome === "injected") { + writeEvent(c.dim(" → injected into the running turn; the agent picks it up at its next step.")); + } else { + writeEvent(c.dim(" → the turn had already ended, so this started a fresh turn.")); + } + } + + const promptResponse = await promptPromise; + printSummary(clueCount, cluesAtSteer, promptResponse.stopReason, steered); + + await agent.request(acp.methods.agent.session.close, { + sessionId: trackedSessionId, + }); + }); + } finally { + await stopAgent(agentProcess); + await rm(workspaceDir, {recursive: true, force: true}); + } +} + +main().catch((error: unknown) => { + console.error("Steering multistep example failed:", error); + process.exitCode = 1; +}); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 00000000..b71b3eec --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": [ + "steering.ts" + ], + "exclude": [] +} diff --git a/package-lock.json b/package-lock.json index 39ae95f5..65ef5ff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.3.0", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^1.2.1", + "@agentclientprotocol/sdk": "^1.3.0", "@openai/codex": "^0.145.0", "diff": "^9.0.0", "open": "^11.0.0", @@ -23,15 +23,15 @@ "@types/node": "^26.1.0", "esbuild": "^0.28.1", "mcp-hello-world": "^1.1.2", - "tsx": "^4.23.0", - "typescript": "^6.0.3", + "tsx": "^4.23.1", + "typescript": "^7.0.2", "vitest": "^4.1.10" } }, "node_modules/@agentclientprotocol/sdk": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz", - "integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -345,8 +345,6 @@ }, "node_modules/@esbuild/linux-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -515,8 +513,6 @@ }, "node_modules/@hono/node-server": { "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "dev": true, "license": "MIT", "engines": { @@ -528,15 +524,11 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -576,8 +568,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, "license": "MIT", "dependencies": { @@ -590,8 +580,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { @@ -615,8 +603,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "dev": true, "license": "MIT", "engines": { @@ -629,8 +615,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", "engines": { @@ -643,8 +627,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "dev": true, "license": "MIT", "engines": { @@ -653,8 +635,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -671,8 +651,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/express": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", "dependencies": { @@ -715,8 +693,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, "license": "MIT", "dependencies": { @@ -737,8 +713,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, "license": "MIT", "engines": { @@ -747,8 +721,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -764,8 +736,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, "license": "MIT", "engines": { @@ -774,8 +744,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, "license": "MIT", "engines": { @@ -787,8 +755,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", "engines": { @@ -797,8 +763,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "dev": true, "license": "MIT", "dependencies": { @@ -814,15 +778,11 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "license": "MIT", "engines": { @@ -831,8 +791,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/send": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -858,8 +816,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, "license": "MIT", "dependencies": { @@ -878,8 +834,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { @@ -897,8 +851,6 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "dev": true, "license": "MIT", "engines": { @@ -910,28 +862,29 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@openai/codex": { "version": "0.145.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "integrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -1002,8 +955,6 @@ "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", "version": "0.145.0-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-x64.tgz", - "integrity": "sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA==", "cpu": [ "x64" ], @@ -1052,8 +1003,6 @@ }, "node_modules/@oxc-project/types": { "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1153,6 +1102,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1170,6 +1122,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1187,6 +1142,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1204,6 +1162,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1215,12 +1176,13 @@ }, "node_modules/@rolldown/binding-linux-x64-gnu": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1232,12 +1194,13 @@ }, "node_modules/@rolldown/binding-linux-x64-musl": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1319,15 +1282,11 @@ }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -1344,8 +1303,6 @@ }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -1355,32 +1312,364 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { @@ -1397,8 +1686,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { @@ -1424,8 +1711,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1437,8 +1722,6 @@ }, "node_modules/@vitest/runner": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { @@ -1451,8 +1734,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { @@ -1467,8 +1748,6 @@ }, "node_modules/@vitest/spy": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1477,8 +1756,6 @@ }, "node_modules/@vitest/utils": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { @@ -1492,8 +1769,6 @@ }, "node_modules/accepts": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, "license": "MIT", "dependencies": { @@ -1506,8 +1781,6 @@ }, "node_modules/ajv": { "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -1523,8 +1796,6 @@ }, "node_modules/ajv-formats": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1541,15 +1812,11 @@ }, "node_modules/array-flatten": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "dev": true, "license": "MIT" }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -1558,8 +1825,6 @@ }, "node_modules/body-parser": { "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "license": "MIT", "dependencies": { @@ -1583,8 +1848,6 @@ }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "dev": true, "license": "MIT", "dependencies": { @@ -1599,8 +1862,6 @@ }, "node_modules/bundle-name": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -1614,8 +1875,6 @@ }, "node_modules/bytes": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, "license": "MIT", "engines": { @@ -1624,8 +1883,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1638,8 +1895,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { @@ -1655,8 +1910,6 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -1665,8 +1918,6 @@ }, "node_modules/content-disposition": { "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1678,8 +1929,6 @@ }, "node_modules/content-type": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, "license": "MIT", "engines": { @@ -1688,15 +1937,11 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "node_modules/cookie": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, "license": "MIT", "engines": { @@ -1705,15 +1950,11 @@ }, "node_modules/cookie-signature": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "dev": true, "license": "MIT" }, "node_modules/cors": { "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "dev": true, "license": "MIT", "dependencies": { @@ -1730,8 +1971,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1745,8 +1984,6 @@ }, "node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", "dependencies": { @@ -1755,8 +1992,6 @@ }, "node_modules/default-browser": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -1771,8 +2006,6 @@ }, "node_modules/default-browser-id": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "license": "MIT", "engines": { "node": ">=18" @@ -1783,8 +2016,6 @@ }, "node_modules/define-lazy-prop": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "license": "MIT", "engines": { "node": ">=12" @@ -1795,8 +2026,6 @@ }, "node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "dev": true, "license": "MIT", "engines": { @@ -1805,8 +2034,6 @@ }, "node_modules/destroy": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true, "license": "MIT", "engines": { @@ -1816,8 +2043,6 @@ }, "node_modules/detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1826,8 +2051,6 @@ }, "node_modules/diff": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", - "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -1835,8 +2058,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1850,15 +2071,11 @@ }, "node_modules/ee-first": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "dev": true, "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, "license": "MIT", "engines": { @@ -1867,8 +2084,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { @@ -1877,8 +2092,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", "engines": { @@ -1887,15 +2100,11 @@ }, "node_modules/es-module-lexer": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -1907,8 +2116,6 @@ }, "node_modules/esbuild": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1949,15 +2156,11 @@ }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true, "license": "MIT" }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -1966,8 +2169,6 @@ }, "node_modules/etag": { "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, "license": "MIT", "engines": { @@ -1976,8 +2177,6 @@ }, "node_modules/eventsource": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, "license": "MIT", "dependencies": { @@ -1989,8 +2188,6 @@ }, "node_modules/eventsource-parser": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "dev": true, "license": "MIT", "engines": { @@ -1999,8 +2196,6 @@ }, "node_modules/expect-type": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2009,8 +2204,6 @@ }, "node_modules/express": { "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2056,8 +2249,6 @@ }, "node_modules/express-rate-limit": { "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "dev": true, "license": "MIT", "dependencies": { @@ -2075,15 +2266,11 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -2099,8 +2286,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -2117,8 +2302,6 @@ }, "node_modules/finalhandler": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "dev": true, "license": "MIT", "dependencies": { @@ -2136,8 +2319,6 @@ }, "node_modules/forwarded": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "dev": true, "license": "MIT", "engines": { @@ -2146,8 +2327,6 @@ }, "node_modules/fresh": { "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "dev": true, "license": "MIT", "engines": { @@ -2171,8 +2350,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -2181,8 +2358,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2206,8 +2381,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2220,8 +2393,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", "engines": { @@ -2233,8 +2404,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -2246,8 +2415,6 @@ }, "node_modules/hasown": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -2259,8 +2426,6 @@ }, "node_modules/hono": { "version": "4.12.28", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", - "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "dev": true, "license": "MIT", "engines": { @@ -2269,8 +2434,6 @@ }, "node_modules/http-errors": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2290,8 +2453,6 @@ }, "node_modules/iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "license": "MIT", "dependencies": { @@ -2303,15 +2464,11 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, "license": "ISC" }, "node_modules/ip-address": { "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -2320,8 +2477,6 @@ }, "node_modules/ipaddr.js": { "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, "license": "MIT", "engines": { @@ -2330,8 +2485,6 @@ }, "node_modules/is-docker": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "license": "MIT", "bin": { "is-docker": "cli.js" @@ -2345,8 +2498,6 @@ }, "node_modules/is-in-ssh": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", "license": "MIT", "engines": { "node": ">=20" @@ -2357,8 +2508,6 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -2375,15 +2524,11 @@ }, "node_modules/is-promise": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true, "license": "MIT" }, "node_modules/is-wsl": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -2397,15 +2542,11 @@ }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/jose": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "dev": true, "license": "MIT", "funding": { @@ -2414,22 +2555,16 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2569,6 +2704,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2590,6 +2728,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2605,12 +2746,13 @@ }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2626,12 +2768,13 @@ }, "node_modules/lightningcss-linux-x64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2689,8 +2832,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2699,8 +2840,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { @@ -2709,8 +2848,6 @@ }, "node_modules/mcp-hello-world": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/mcp-hello-world/-/mcp-hello-world-1.1.2.tgz", - "integrity": "sha512-g38YEyrda27qouiryVN3/fKOetGsaacXI58hIVLQlsJmp+L/T9vCG89eJiuxQZAqRlZ7amBjPgdgKmcZK7kY+w==", "dev": true, "license": "MIT", "dependencies": { @@ -2728,8 +2865,6 @@ }, "node_modules/mcp-hello-world/node_modules/zod": { "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", "funding": { @@ -2738,8 +2873,6 @@ }, "node_modules/media-typer": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "dev": true, "license": "MIT", "engines": { @@ -2748,8 +2881,6 @@ }, "node_modules/merge-descriptors": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "dev": true, "license": "MIT", "funding": { @@ -2758,8 +2889,6 @@ }, "node_modules/methods": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, "license": "MIT", "engines": { @@ -2768,8 +2897,6 @@ }, "node_modules/mime": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", "bin": { @@ -2781,8 +2908,6 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", "engines": { @@ -2791,8 +2916,6 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { @@ -2804,15 +2927,11 @@ }, "node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -2830,8 +2949,6 @@ }, "node_modules/negotiator": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", "engines": { @@ -2840,8 +2957,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", "engines": { @@ -2850,8 +2965,6 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -2863,8 +2976,6 @@ }, "node_modules/obug": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2877,8 +2988,6 @@ }, "node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dev": true, "license": "MIT", "dependencies": { @@ -2890,8 +2999,6 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -2900,8 +3007,6 @@ }, "node_modules/open": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", "license": "MIT", "dependencies": { "default-browser": "^5.4.0", @@ -2920,8 +3025,6 @@ }, "node_modules/parseurl": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "dev": true, "license": "MIT", "engines": { @@ -2930,8 +3033,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -2940,29 +3041,21 @@ }, "node_modules/path-to-regexp": { "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "dev": true, "license": "MIT" }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2974,8 +3067,6 @@ }, "node_modules/pkce-challenge": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "dev": true, "license": "MIT", "engines": { @@ -2984,8 +3075,6 @@ }, "node_modules/postcss": { "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -3013,8 +3102,6 @@ }, "node_modules/powershell-utils": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", "license": "MIT", "engines": { "node": ">=20" @@ -3025,8 +3112,6 @@ }, "node_modules/proxy-addr": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "dev": true, "license": "MIT", "dependencies": { @@ -3039,8 +3124,6 @@ }, "node_modules/qs": { "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3056,8 +3139,6 @@ }, "node_modules/range-parser": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "dev": true, "license": "MIT", "engines": { @@ -3066,8 +3147,6 @@ }, "node_modules/raw-body": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "dev": true, "license": "MIT", "dependencies": { @@ -3082,8 +3161,6 @@ }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3099,8 +3176,6 @@ }, "node_modules/react": { "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "dev": true, "license": "MIT", "peer": true, @@ -3110,8 +3185,6 @@ }, "node_modules/react-dom": { "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "dev": true, "license": "MIT", "peer": true, @@ -3124,8 +3197,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { @@ -3134,8 +3205,6 @@ }, "node_modules/rolldown": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { @@ -3168,8 +3237,6 @@ }, "node_modules/router": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3185,8 +3252,6 @@ }, "node_modules/router/node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3203,15 +3268,11 @@ }, "node_modules/router/node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/router/node_modules/path-to-regexp": { "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", "funding": { @@ -3221,8 +3282,6 @@ }, "node_modules/run-applescript": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "license": "MIT", "engines": { "node": ">=18" @@ -3233,8 +3292,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true, "funding": [ { @@ -3254,23 +3311,17 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "license": "MIT" }, "node_modules/scheduler": { "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "dev": true, "license": "MIT", "peer": true }, "node_modules/send": { "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "dev": true, "license": "MIT", "dependencies": { @@ -3294,15 +3345,11 @@ }, "node_modules/send/node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/serve-static": { "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "dev": true, "license": "MIT", "dependencies": { @@ -3317,15 +3364,11 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true, "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -3337,8 +3380,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -3347,8 +3388,6 @@ }, "node_modules/side-channel": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3367,8 +3406,6 @@ }, "node_modules/side-channel-list": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { @@ -3384,8 +3421,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { @@ -3403,8 +3438,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { @@ -3423,15 +3456,11 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3440,15 +3469,11 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/statuses": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { @@ -3457,22 +3482,16 @@ }, "node_modules/std-env": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -3481,8 +3500,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -3498,8 +3515,6 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3508,8 +3523,6 @@ }, "node_modules/toidentifier": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true, "license": "MIT", "engines": { @@ -3525,9 +3538,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", - "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "version": "4.23.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", + "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", "dev": true, "license": "MIT", "dependencies": { @@ -3545,8 +3558,6 @@ }, "node_modules/type-is": { "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3558,30 +3569,47 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, "license": "MIT", "engines": { @@ -3590,8 +3618,6 @@ }, "node_modules/utils-merge": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "dev": true, "license": "MIT", "engines": { @@ -3600,8 +3626,6 @@ }, "node_modules/vary": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "dev": true, "license": "MIT", "engines": { @@ -3610,8 +3634,6 @@ }, "node_modules/vite": { "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3688,8 +3710,6 @@ }, "node_modules/vitest": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { @@ -3778,8 +3798,6 @@ }, "node_modules/vscode-jsonrpc": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", - "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -3787,8 +3805,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -3803,8 +3819,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -3820,15 +3834,11 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/wsl-utils": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", "license": "MIT", "dependencies": { "is-wsl": "^3.1.0", @@ -3843,8 +3853,6 @@ }, "node_modules/zod": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -3852,8 +3860,6 @@ }, "node_modules/zod-to-json-schema": { "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "dev": true, "license": "ISC", "peerDependencies": { diff --git a/package.json b/package.json index 4bd61928..b7ad5078 100644 --- a/package.json +++ b/package.json @@ -33,20 +33,22 @@ "package:win-x64": "cd dist/bin && zip acp-extension-codex-x64-windows.zip acp-extension-codex-x64-windows.exe", "package:win-arm64": "cd dist/bin && zip acp-extension-codex-arm64-windows.zip acp-extension-codex-arm64-windows.exe", "start": "node --import tsx src/index.ts", + "example:steering": "node --import tsx examples/steering.ts", + "example:steering:multistep": "node --import tsx examples/steering.ts", "generate-types": "./node_modules/.bin/codex app-server generate-ts --out src/app-server", "test": "vitest run", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run src/__tests__/CodexACPAgent/e2e", "test:watch": "vitest", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" }, - "homepage": "https://github.com/Leeeon233/acp-extension-codex#readme", + "homepage": "https://github.com/loro-dev/acp-extension-codex#readme", "bugs": { - "url": "https://github.com/Leeeon233/acp-extension-codex/issues" + "url": "https://github.com/loro-dev/acp-extension-codex/issues" }, "repository": { "type": "git", - "url": "git+https://github.com/Leeeon233/acp-extension-codex.git" + "url": "git+https://github.com/loro-dev/acp-extension-codex.git" }, "keywords": [ "codex", @@ -63,12 +65,12 @@ "@types/node": "^26.1.0", "esbuild": "^0.28.1", "mcp-hello-world": "^1.1.2", - "tsx": "^4.23.0", - "typescript": "^6.0.3", + "tsx": "^4.23.1", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.2.1", + "@agentclientprotocol/sdk": "^1.3.0", "@openai/codex": "^0.145.0", "diff": "^9.0.0", "open": "^11.0.0", diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index ddcdd780..5e6a377e 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -1,6 +1,7 @@ import type { AvailableCommand, ClientContext, + ContentBlock, LoadSessionResponse, NewSessionResponse, ResumeSessionResponse, @@ -12,6 +13,8 @@ export const ACP_EXT_SESSION_USAGE_UPDATE_METHOD = "_acp_ext:session_usage_updat export const ACP_EXT_SESSION_RATE_LIMITS_METHOD = "_acp_ext:session_rate_limits"; export const ACP_EXT_CODEX_PROPOSED_PLAN_METHOD = "_acp_ext:codex_proposed_plan"; export const CODEX_STEER_APPLIED_METHOD = "_codex/steerApplied"; +export const SESSION_STEERING_METHOD = "_session/steering"; +export const GOAL_CONTROL_METHOD = "_codex/session/goal_control"; export function getLodyForkTurnId(meta: unknown): string | null { if (typeof meta !== "object" || meta === null) return null; const lody = (meta as Record)["lody"]; @@ -27,6 +30,7 @@ export function getLodyForkTurnId(meta: unknown): string | null { export type CodexSteerCapability = { version: 1; + method: typeof SESSION_STEERING_METHOD; appliedNotification: typeof CODEX_STEER_APPLIED_METHOD; upstreamTurn: "same"; configPolicy: "active"; @@ -34,21 +38,12 @@ export type CodexSteerCapability = { export const CODEX_STEER_CAPABILITY: CodexSteerCapability = { version: 1, + method: SESSION_STEERING_METHOD, appliedNotification: CODEX_STEER_APPLIED_METHOD, upstreamTurn: "same", configPolicy: "active", }; -export function getCodexSteerId(meta: unknown): string | null { - if (typeof meta !== "object" || meta === null) return null; - const codex = (meta as Record)["codex"]; - if (typeof codex !== "object" || codex === null) return null; - const steer = (codex as Record)["steer"]; - if (typeof steer !== "object" || steer === null) return null; - const id = (steer as Record)["id"]; - return typeof id === "string" && id.length > 0 ? id : null; -} - export type LegacySessionModel = { modelId: string; name: string; @@ -123,11 +118,15 @@ export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest | LegacySetSessionModelExtRequest + | SessionSteeringExtRequest + | GoalControlExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" || request.method === "authentication/logout" - || request.method === LEGACY_SET_SESSION_MODEL_METHOD; + || request.method === LEGACY_SET_SESSION_MODEL_METHOD + || request.method === GOAL_CONTROL_METHOD + || request.method === SESSION_STEERING_METHOD; } export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } @@ -141,9 +140,46 @@ export type LegacySetSessionModelExtRequest = { params: LegacySetSessionModelRequest; } +export type GoalControlRequest = { + sessionId: SessionId; + action: "pause" | "clear"; +} + +export type GoalControlExtRequest = { + method: typeof GOAL_CONTROL_METHOD; + params: GoalControlRequest; +} + export async function legacySetSessionModel( connection: Pick, params: LegacySetSessionModelRequest, ): Promise { return await connection.request(LEGACY_SET_SESSION_MODEL_METHOD, params); } + +export type SessionSteerRequest = { + sessionId: SessionId; + prompt: ContentBlock[]; + /** + * Lody application correlation. When present, the adapter only injects + * into the active turn and confirms application through the advertised + * committed-user-message notification; it never starts a fallback turn. + */ + steerId?: string; +} + +export type SessionSteeringResponse = { + outcome: "injected" | "startedNewTurn" | "failed"; +} + +export type SessionSteeringExtRequest = { + method: typeof SESSION_STEERING_METHOD; + params: SessionSteerRequest; +} + +export async function steerSessionWithFallback( + connection: Pick, + params: SessionSteerRequest, +): Promise { + return await connection.request(SESSION_STEERING_METHOD, params); +} diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f77054fa..0edc021e 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -12,7 +12,6 @@ import open from "open"; import type {Disposable} from "vscode-jsonrpc"; import type { ClientInfo, - CollaborationMode, ReasoningEffort, ServiceTier, ServerNotification @@ -36,14 +35,18 @@ import type { SkillsListResponse, SandboxPolicy, Thread, + ThreadGoal, ThreadGoalStatus, ThreadSourceKind, TurnCompletedNotification, TurnStartParams, + TurnSteerResponse, UserInput, } from "./app-server/v2"; import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; +import {createCodexCollaborationMode} from "./CollaborationModeConfig"; +import type {ModeKind} from "./app-server/ModeKind"; /** * Well-known provider id for the client-configurable custom LLM gateway. @@ -75,6 +78,7 @@ export class CodexAcpClient { private pendingAccountUpdated: Promise | null = null; private readonly sessionNotificationQueues = new Map>(); private skillExtraRoots: string[] = []; + private configPath: string | null = null; constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) { @@ -89,7 +93,7 @@ export class CodexAcpClient { }; async initialize(request: acp.InitializeRequest): Promise { - await this.codexClient.initialize({ + const response = await this.codexClient.initialize({ capabilities: { experimentalApi: true, requestAttestation: false, @@ -100,6 +104,11 @@ export class CodexAcpClient { title: request.clientInfo?.title ?? this.defaultClientInfo.title, } }); + this.configPath = response?.codexHome ?? null; + } + + getHomePath(): string | null { + return this.configPath; } async authenticate(authRequest: acp.AuthenticateRequest): Promise { @@ -346,6 +355,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, @@ -375,6 +385,7 @@ export class CodexAcpClient { sessionId: response.thread.id, currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, @@ -402,6 +413,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, thread: historyResponse.thread, @@ -432,6 +444,7 @@ export class CodexAcpClient { sessionId: response.thread.id, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, @@ -466,6 +479,11 @@ export class CodexAcpClient { await this.codexClient.runCompact({threadId: sessionId}); } + async getGoal(sessionId: string): Promise { + const response = await this.codexClient.threadGoalGet({threadId: sessionId}); + return response?.goal ?? null; + } + async setGoal( sessionId: string, objective: string, @@ -478,11 +496,18 @@ export class CodexAcpClient { }, onTurnStarted); } - async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { + async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { + let updatedGoal: ThreadGoal | null = null; await this.codexClient.runGoalSet({ threadId: sessionId, status, + }, undefined, undefined, (goal) => { + updatedGoal = goal; }); + if (updatedGoal === null) { + throw new Error(`Goal update for session ${sessionId} returned no goal`); + } + return updatedGoal; } async resumeGoal( @@ -546,11 +571,16 @@ export class CodexAcpClient { private async getConfigMcpServerNames(projectPath: string): Promise> { const response = await this.codexClient.configRead({ includeLayers: true, cwd: projectPath }); - const mcpServers = response?.config?.["mcp_servers"]; - if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) { + const effectiveMcpServers = response?.config?.["mcp_servers"]; + const configLayers = response?.layers ?? []; + const layerMcpServers = configLayers.map(layer => { + return isJsonObject(layer.config) ? layer.config["mcp_servers"] : undefined; + }); + const configuredMcpServers = [effectiveMcpServers, ...layerMcpServers].filter(isJsonObject); + if (configuredMcpServers.length === 0) { return new Set(); } - return new Set(Object.keys(mcpServers)); + return new Set(configuredMcpServers.flatMap(server => Object.keys(server))); } getModelProvider(): string | null { @@ -704,7 +734,6 @@ export class CodexAcpClient { agentMode: AgentMode, modelId: ModelId, serviceTier: ServiceTier | null, - collaborationMode: CollaborationMode | null, disableSummary: boolean, cwd: string, additionalDirectories: string[], @@ -717,7 +746,7 @@ export class CodexAcpClient { if (shouldCancel?.()) { return null; } - const params: TurnStartParams & { collaborationMode?: CollaborationMode } = { + const params: TurnStartParams = { threadId: request.sessionId, input: input, approvalPolicy: agentMode.approvalPolicy, @@ -728,24 +757,18 @@ export class CodexAcpClient { model: modelId.model, serviceTier: serviceTier, }; - if (collaborationMode !== null) { - params.collaborationMode = collaborationMode; - } return await this.codexClient.runTurn(params, onTurnStarted); } - async sendSteer( - request: acp.PromptRequest, - expectedTurnId: string, - steerId: string, - ): Promise { - const response = await this.codexClient.turnSteer({ - threadId: request.sessionId, - input: buildPromptItems(request.prompt), - expectedTurnId, - clientUserMessageId: steerId, + async setCollaborationMode(sessionId: string, mode: ModeKind, currentModelId: string): Promise { + await this.codexClient.threadSettingsUpdate({ + threadId: sessionId, + collaborationMode: createCodexCollaborationMode(mode, currentModelId), }); - return response.turnId; + } + + private getCollaborationMode(sessionId: string): ModeKind { + return this.codexClient.getThreadSettings(sessionId)?.collaborationMode.mode ?? "default"; } resolveTurnInterrupted(params: { threadId: string, turnId: string }): void { @@ -874,6 +897,20 @@ export class CodexAcpClient { }); } + async steerTurn(params: { + threadId: string; + turnId: string; + prompt: acp.ContentBlock[]; + steerId?: string; + }): Promise { + return await this.codexClient.turnSteer({ + threadId: params.threadId, + expectedTurnId: params.turnId, + input: buildPromptItems(params.prompt), + ...(params.steerId ? {clientUserMessageId: params.steerId} : {}), + }); + } + async fetchAvailableModels(): Promise { const models: Model[] = []; let cursor: string | null = null; @@ -918,6 +955,7 @@ export type SessionMetadata = { sessionId: string, currentModelId: string, models: Model[], + collaborationMode: ModeKind, modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: string[], diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 64783a23..8fb03e92 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,13 +1,13 @@ import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; -import {CodexEventHandler} from "./CodexEventHandler"; +import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; import type {McpStartupResult} from "./CodexAppServerClient"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; -import type {CollaborationMode, InputModality, ReasoningEffort, ServerNotification} from "./app-server"; +import type {InputModality, ReasoningEffort, ServerNotification} from "./app-server"; import type { Account, Model, @@ -21,6 +21,14 @@ import type { import type {RateLimitsMap} from "./RateLimitsMap"; import {ModelId} from "./ModelId"; import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; +import { + COLLABORATION_MODE_CONFIG_ID, + createCollaborationModeConfigOption, + DEFAULT_COLLABORATION_MODE, + parseCollaborationMode, + PLAN_COLLABORATION_MODE, +} from "./CollaborationModeConfig"; +import type {ModeKind} from "./app-server/ModeKind"; import { createModelConfigOption, createReasoningEffortConfigOption, @@ -31,6 +39,7 @@ import { import type {TokenCount} from "./TokenCount"; import {toPromptUsage} from "./TokenCount"; import {CodexCommands} from "./CodexCommands"; +import {SteeringQueue} from "./SteeringQueue"; import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; @@ -44,9 +53,12 @@ import { type LegacySetSessionModelResponse, CODEX_STEER_CAPABILITY, CODEX_STEER_APPLIED_METHOD, - getCodexSteerId, + type SessionSteerRequest, + type SessionSteeringResponse, + GOAL_CONTROL_METHOD, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, + SESSION_STEERING_METHOD, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, @@ -58,6 +70,7 @@ import { createImageGenerationUpdate, createImageViewUpdate, createMcpToolCallUpdate, + createSubAgentActivityUpdate, formatWebSearchTitle, } from "./CodexToolCallMapper"; import { @@ -69,28 +82,26 @@ import { modelSupportsFast, resolveFastServiceTier, } from "./FastModeConfig"; -import { - createPlanModeConfigOption, - PLAN_MODE_CONFIG_ID, - PLAN_MODE_OFF, - PLAN_MODE_ON, -} from "./PlanModeConfig"; import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode"; import {sanitizeReasoningParts} from "./ReasoningText"; +import {clientSupportsPlanUpdates} from "./PlanCapabilities"; import { createCodexAgentMessageMeta, + createCodexMessagePhaseMeta, createAgentTextMessageChunk, createAgentTextThoughtChunk, createUserMessageChunk, } from "./ContentChunks"; +import { + sameThreadGoalSnapshot, + type ThreadGoalSnapshot, + toThreadGoalSnapshot, +} from "./ThreadGoalSnapshot"; -export interface ThreadGoalSnapshot { - objective: string; - status: ThreadGoalStatus; - tokenBudget: number | null; -} +const IMPLEMENT_PLAN_OPTION_ID = "implement_plan"; +const REVISE_PLAN_OPTION_ID = "revise_plan"; export interface SessionState { sessionId: string, @@ -99,6 +110,7 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; totalTokenUsage: TokenCount | null; @@ -111,11 +123,10 @@ export interface SessionState { additionalDirectories: string[]; fastModeEnabled: boolean; currentModelSupportsFast: boolean; - planModeEnabled: boolean; - planModeExplicitlySet: boolean; sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; currentGoal?: ThreadGoalSnapshot | null; + goalRevision: number; sessionTitle: string | null; sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; } @@ -153,7 +164,6 @@ interface ActivePrompt { cancelSignal: Promise; signal: AbortSignal; currentTurn: { threadId: string, turnId: string } | null; - outcome: Promise | null; requestCancel: () => void; requestClose: () => void; complete: () => void; @@ -187,6 +197,7 @@ export class CodexAcpServer { private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; private readonly pendingSteers: Map>; + private readonly steeringQueues: Map; private readonly closingSessions: Map; private readonly sessionGenerations: Map; private readonly sessionOpenGenerations: Map; @@ -203,6 +214,7 @@ export class CodexAcpServer { this.pendingTurnStarts = new Map(); this.activePrompts = new Map(); this.pendingSteers = new Map(); + this.steeringQueues = new Map(); this.closingSessions = new Map(); this.sessionGenerations = new Map(); this.sessionOpenGenerations = new Map(); @@ -272,6 +284,11 @@ export class CodexAcpServer { }, }, authMethods: getCodexAuthMethods(_params.clientCapabilities), + _meta: { + steering: { + supported: true, + }, + }, }; } @@ -289,6 +306,27 @@ export class CodexAcpServer { } case LEGACY_SET_SESSION_MODEL_METHOD: return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); + case SESSION_STEERING_METHOD: + return await this.executeOrQueueSteeringRequest(this.parseSessionSteerParams(methodRequest.params)); + case GOAL_CONTROL_METHOD: { + const sessionState = this.sessions.get(methodRequest.params.sessionId); + if (!sessionState) { + throw RequestError.invalidParams(undefined, `Unknown session: ${methodRequest.params.sessionId}`); + } + const sessionGeneration = this.getSessionGeneration(sessionState.sessionId); + if (methodRequest.params.action === "pause") { + const goal = await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, "paused")); + if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) { + await this.publishGoalSnapshot(sessionState, toThreadGoalSnapshot(goal), false); + } + } else if (methodRequest.params.action === "clear") { + await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionState.sessionId)); + if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) { + await this.publishGoalSnapshot(sessionState, null, false); + } + } + return {}; + } } } @@ -335,6 +373,10 @@ export class CodexAcpServer { await this.refreshSessionsAuthState(null); throw RequestError.internalError(`${(e.message)}\n\nYou have been logged out. Please try again.`); } + const configPath = this.codexAcpClient.getHomePath() ?? "global"; + if (e.message.includes("load config")) { + throw RequestError.internalError(`${e.message}\n\nCheck ${configPath} and project .codex directories, especially their config.toml files, or any CODEX_CONFIG override.`); + } } private beginSessionOpen(sessionId: string): number { @@ -507,6 +549,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -519,10 +562,9 @@ export class CodexAcpServer { additionalDirectories: sessionMetadata.additionalDirectories, fastModeEnabled: sessionMetadata.currentServiceTier === "fast", currentModelSupportsFast: currentModelSupportsFast, - planModeEnabled: false, - planModeExplicitlySet: false, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: operation.kind === "new" ? "unset" : "unknown", }; @@ -539,6 +581,9 @@ export class CodexAcpServer { } const availableCommands = await this.availableCommands.getAvailableCommands(sessionState); + if ("sessionId" in request) { + this.publishCurrentGoalAsync(sessionState, openedSession.generation); + } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -714,6 +759,8 @@ export class CodexAcpServer { this.pendingMcpStartupSessions.delete(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); this.activePrompts.delete(params.sessionId); + this.pendingSteers.delete(params.sessionId); + this.steeringQueues.delete(params.sessionId); } this.endSessionCloseFence(params.sessionId); } @@ -863,16 +910,24 @@ export class CodexAcpServer { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); + await this.applySessionConfigOption(sessionState, params); + + return { + configOptions: this.createSessionConfigOptions(sessionState), + }; + } + + private async applySessionConfigOption(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): Promise { switch (params.configId) { case FAST_MODE_CONFIG_ID: this.applyFastModeChange(sessionState, params); break; - case PLAN_MODE_CONFIG_ID: - this.applyPlanModeChange(sessionState, this.stringConfigValue(params)); - break; case MODE_CONFIG_ID: this.applyModeChange(sessionState, this.stringConfigValue(params)); break; + case COLLABORATION_MODE_CONFIG_ID: + await this.applyCollaborationModeChange(sessionState, this.stringConfigValue(params)); + break; case MODEL_CONFIG_ID: this.applyModelChange(sessionState, this.stringConfigValue(params)); break; @@ -882,10 +937,6 @@ export class CodexAcpServer { default: throw RequestError.invalidParams(); } - - return { - configOptions: this.createSessionConfigOptions(sessionState), - }; } private applyFastModeChange(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): void { @@ -900,14 +951,6 @@ export class CodexAcpServer { sessionState.fastModeEnabled = value === FAST_MODE_ON; } - private applyPlanModeChange(sessionState: SessionState, value: string): void { - if (value !== PLAN_MODE_ON && value !== PLAN_MODE_OFF) { - throw RequestError.invalidParams(); - } - sessionState.planModeEnabled = value === PLAN_MODE_ON; - sessionState.planModeExplicitlySet = true; - } - private stringConfigValue(params: acp.SetSessionConfigOptionRequest): string { if (typeof params.value !== "string") { throw RequestError.invalidParams(); @@ -923,6 +966,15 @@ export class CodexAcpServer { sessionState.agentMode = newMode; } + private async applyCollaborationModeChange(sessionState: SessionState, value: string): Promise { + const mode = parseCollaborationMode(value); + if (mode === null) { + throw RequestError.invalidParams(); + } + await this.codexAcpClient.setCollaborationMode(sessionState.sessionId, mode, sessionState.currentModelId); + sessionState.collaborationMode = mode; + } + private applyModelChange(sessionState: SessionState, value: string): void { const model = sessionState.availableModels.find(m => m.id === value); if (!model) { @@ -997,10 +1049,286 @@ export class CodexAcpServer { }; } + /** + * Handles one incoming steering request, serialising it against any other + * steer already in flight for the same session. + * + * Every session gets its own {@link SteeringQueue}: the request is enqueued + * and awaited, so concurrent steers for one session run strictly one at a + * time, in arrival order, and can never race to inject into — or start — + * rival turns. Steers for different sessions use different queues and run + * concurrently. Once the queue drains to idle it is removed from the map, + * so no per-session entry leaks after the session goes quiet (the identity + * check guards against deleting a queue a later request has since reused). + * + * @param params The target session id and the prompt to steer with. + * @returns Whether the prompt joined the active turn ("injected"), started a + * new one ("startedNewTurn"), or could not be applied ("failed"); see + * {@link performSteeringRequest}. + */ + async executeOrQueueSteeringRequest(params: SessionSteerRequest): Promise { + const queue = this.getSteeringQueue(params.sessionId); + try { + return await queue.enqueue(params); + } catch (error) { + if (error instanceof RequestError) { + throw error; + } + logger.error(`Steering request for session ${params.sessionId} failed`, error); + return {outcome: "failed"}; + } finally { + if (queue.isIdle && this.steeringQueues.get(params.sessionId) === queue) { + this.steeringQueues.delete(params.sessionId); + } + } + } + + /** + * Returns the steering queue for a session, creating and registering it on + * first use. + * + * @param sessionId The session whose steering queue is required. + * @returns The session's existing queue, or a freshly created one. + */ + private getSteeringQueue(sessionId: string): SteeringQueue { + let queue = this.steeringQueues.get(sessionId); + if (!queue) { + queue = new SteeringQueue((params) => this.performSteeringRequest(params)); + this.steeringQueues.set(sessionId, queue); + } + return queue; + } + + /** + * Delivers a steering prompt to the session: injects it into the live turn + * when there is one, otherwise starts a new turn. + * + * @param params The target session id and the prompt to steer with. + * @returns "injected" when the prompt joined an existing turn, otherwise the + * outcome of starting a new turn. + */ + private async performSteeringRequest(params: SessionSteerRequest): Promise { + logger.log("Steering session requested", { + sessionId: params.sessionId, + prompt: params.prompt, + }); + const sessionState = this.getSessionState(params.sessionId); + this.assertSteerInputSupported(params, sessionState); + + const turnId = await this.getSteerableTurnId(sessionState); + if (turnId) { + const injected = await this.injectSteerIntoActiveTurn(params, turnId, sessionState); + if (injected) { + logger.log("Steering session injected", {sessionId: params.sessionId, turnId}); + return {outcome: "injected"}; + } + } + if (params.steerId) { + throw RequestError.invalidRequest("No active Codex turn to steer"); + } + return await this.startNewTurnFromSteering(params); + } + + /** + * Rejects a steering prompt whose content the active model cannot accept + * (currently: image blocks on a text-only model). + */ + private assertSteerInputSupported(params: SessionSteerRequest, sessionState: SessionState): void { + const hasImage = params.prompt.some(block => block.type === "image"); + if (hasImage && !sessionState.supportedInputModalities.includes("image")) { + throw RequestError.invalidRequest("The current model does not support image input"); + } + } + + /** + * Attempts to inject the prompt into the given running turn. + * + * A failed injection is fatal only when the turn is still the session's + * current turn and Codex reported something other than "no active turn to + * steer". Otherwise the turn has already ended underneath us and the caller + * should start a new turn instead. + * + * @returns true when the prompt was injected; false when the caller should + * fall back to starting a new turn. + */ + private async injectSteerIntoActiveTurn( + params: SessionSteerRequest, + turnId: string, + sessionState: SessionState, + ): Promise { + const activePrompt = this.activePrompts.get(params.sessionId); + const activeTurn = activePrompt?.currentTurn; + if (params.steerId) { + const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : ""; + if (firstText.startsWith("/")) { + throw RequestError.invalidRequest("Slash commands cannot steer an active Codex turn"); + } + if ( + !activePrompt + || !activeTurn + || activeTurn.turnId !== turnId + || activePrompt.signal.aborted + ) { + return false; + } + } + + const pending = params.steerId + ? (this.pendingSteers.get(params.sessionId) ?? new Map()) + : null; + if (params.steerId && activePrompt && pending) { + if (pending.has(params.steerId)) { + throw RequestError.invalidRequest(`Duplicate Codex steer id: ${params.steerId}`); + } + pending.set(params.steerId, {activePrompt, turnId}); + this.pendingSteers.set(params.sessionId, pending); + } + + try { + const response = await this.runWithProcessCheck(() => this.codexAcpClient.steerTurn({ + threadId: params.steerId && activeTurn ? activeTurn.threadId : params.sessionId, + turnId, + prompt: params.prompt, + ...(params.steerId ? {steerId: params.steerId} : {}), + })); + if (response.turnId !== turnId) { + throw RequestError.internalError( + {expectedTurnId: turnId, actualTurnId: response.turnId}, + `Codex steered unexpected turn ${response.turnId}; expected ${turnId}`, + ); + } + return true; + } catch (err) { + if (params.steerId && activePrompt && pending?.get(params.steerId)?.activePrompt === activePrompt) { + pending.delete(params.steerId); + if (pending.size === 0) this.pendingSteers.delete(params.sessionId); + } + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + const turnStillActive = sessionState.currentTurnId === turnId; + if (turnStillActive && !this.isNoActiveTurnToSteerError(err)) { + throw err; + } + return false; + } + } + + /** + * Starts a new turn from a steering prompt when there is no live turn to + * inject into, and returns as soon as that turn is running. + * + * Waits for any previous prompt to drain first, then re-checks that the + * session is not closing — the await above is a window during which a close + * request can arrive. + * + * @param params The target session id and the prompt to steer with. + * @returns "startedNewTurn" once the turn is running; throws if the prompt + * fails or is cancelled before the turn starts. + */ + private async startNewTurnFromSteering(params: SessionSteerRequest): Promise { + // A prompt can outlive its turn (post-turn cleanup runs before it leaves + // activePrompts), so a steer can miss the turn while the prompt is still + // winding down. Starting a new turn now would run a second prompt on the + // same session, so wait for the current one to drain first (a no-op when idle). + const previousPrompt = this.activePrompts.get(params.sessionId); + await previousPrompt?.completion; + if (this.sessionIsClosing(params.sessionId)) { + throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); + } + + return await new Promise((resolve, reject) => { + let turnStarted = false; + const promptDone = this.prompt(params, undefined, () => { + turnStarted = true; + logger.log("Steering session started a new turn", {sessionId: params.sessionId}); + // The new turn is now running. This is the success path: answer the + // steer immediately ("a turn was started") and let prompt() finish the + // turn in the background. + resolve({outcome: "startedNewTurn"}); + }); + promptDone.then( + (response) => { + if (!turnStarted && response.stopReason === "cancelled") { + // The prompt ended without the turn ever starting, because it + // was cancelled. The steer never took, so fail the request. + reject(RequestError.invalidRequest(`Session ${params.sessionId} was cancelled before the steering turn started`)); + } else { + // Either the turn already started (this is a no-op after the + // resolve in the callback above), or the prompt finished + // without ever starting a turn and was not cancelled (e.g. a + // command-only turn). Both count as a successfully accepted steer. + resolve({outcome: "startedNewTurn"}); + } + }, + (error: unknown) => { + if (turnStarted) { + // The turn had already started, so the steer was already + // answered "startedNewTurn". This is a failure of a turn running + // in the background — nothing to return, just log it. + logger.error(`Steering-started prompt for session ${params.sessionId} failed`, error); + } else { + // The prompt failed before the turn started. The steer never + // took, so surface the failure to the caller. + reject(error); + } + }, + ); + }); + } + + private isNoActiveTurnToSteerError(error: unknown): boolean { + const messages = error instanceof Error ? [error.message] : []; + if (typeof error === "object" && error !== null && "data" in error) { + const data = (error as {data?: unknown}).data; + if (typeof data === "string") { + messages.push(data); + } else if (typeof data === "object" && data !== null && "details" in data) { + const details = (data as {details?: unknown}).details; + if (typeof details === "string") { + messages.push(details); + } + } + } + return messages.some(message => message.toLowerCase().includes("no active turn to steer")); + } + + private async getSteerableTurnId(sessionState: SessionState): Promise { + if (this.sessionIsClosing(sessionState.sessionId)) { + return null; + } + if (sessionState.currentTurnId) { + return sessionState.currentTurnId; + } + + const pendingTurnStart = this.pendingTurnStarts.get(sessionState.sessionId); + if (!pendingTurnStart) { + return null; + } + return await pendingTurnStart.promise; + } + + private parseSessionSteerParams(params: Record): SessionSteerRequest { + const sessionId = params["sessionId"]; + const prompt = params["prompt"]; + const steerId = params["steerId"]; + if ( + typeof sessionId !== "string" + || !Array.isArray(prompt) + || (steerId !== undefined && (typeof steerId !== "string" || steerId.length === 0)) + ) { + throw RequestError.invalidParams(); + } + return { + sessionId: sessionId, + prompt: prompt as acp.ContentBlock[], + ...(typeof steerId === "string" ? {steerId} : {}), + }; + } + private createSessionConfigOptions(sessionState: SessionState): Array { const currentModelId = ModelId.fromString(sessionState.currentModelId); const configOptions = [ sessionState.agentMode.toConfigOption(), + createCollaborationModeConfigOption(sessionState.collaborationMode), createModelConfigOption(sessionState.availableModels, currentModelId.model), ]; if (sessionState.supportedReasoningEfforts.length > 0) { @@ -1014,7 +1342,6 @@ export class CodexAcpServer { this.booleanConfigOptionsSupported, )); } - configOptions.push(createPlanModeConfigOption(sessionState.planModeEnabled)); return configOptions; } @@ -1034,18 +1361,65 @@ export class CodexAcpServer { return !isJetBrains2026_1Client(this.clientInfo); } - private createPlanModeCollaborationMode(sessionState: SessionState, modelId: ModelId): CollaborationMode | null { - if (!sessionState.planModeEnabled && !sessionState.planModeExplicitlySet) { - return null; + private publishCurrentGoalAsync(sessionState: SessionState, sessionGeneration: number): void { + void this.publishCurrentGoalBestEffort(sessionState, sessionGeneration, true); + } + + private async publishCurrentGoalBestEffort( + sessionState: SessionState, + sessionGeneration: number, + force: boolean, + ): Promise { + try { + await this.publishCurrentGoal(sessionState, sessionGeneration, force); + } catch (err) { + logger.error(`Failed to publish current goal for session ${sessionState.sessionId}`, err); } - return { - mode: sessionState.planModeEnabled ? "plan" : "default", - settings: { - model: modelId.model, - reasoning_effort: modelId.effort as ReasoningEffort, - developer_instructions: null, + } + + private async publishCurrentGoal( + sessionState: SessionState, + sessionGeneration: number, + force: boolean, + ): Promise { + const requestRevision = ++sessionState.goalRevision; + const goal = await this.runWithProcessCheck(() => this.codexAcpClient.getGoal(sessionState.sessionId)); + const snapshot = goal === null ? null : toThreadGoalSnapshot(goal); + if (!this.goalPublishIsCurrent(sessionState, sessionGeneration) + || sessionState.goalRevision !== requestRevision) { + return; + } + await this.publishGoalSnapshot(sessionState, snapshot, force, false); + } + + private goalPublishIsCurrent(sessionState: SessionState, sessionGeneration: number): boolean { + return this.sessions.get(sessionState.sessionId) === sessionState + && this.getSessionGeneration(sessionState.sessionId) === sessionGeneration + && !this.sessionIsClosing(sessionState.sessionId); + } + + private async publishGoalSnapshot( + sessionState: SessionState, + snapshot: ThreadGoalSnapshot | null, + force: boolean, + incrementRevision = true, + ): Promise { + if (incrementRevision) { + sessionState.goalRevision += 1; + } + if (!force && sameThreadGoalSnapshot(sessionState.currentGoal, snapshot)) { + return; + } + sessionState.currentGoal = snapshot; + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "session_info_update", + _meta: { + codex: { + goal: snapshot, + }, }, - }; + }); } private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { @@ -1132,6 +1506,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -1144,10 +1519,9 @@ export class CodexAcpServer { additionalDirectories: sessionMetadata.additionalDirectories, fastModeEnabled: sessionMetadata.currentServiceTier === "fast", currentModelSupportsFast: currentModelSupportsFast, - planModeEnabled: false, - planModeExplicitlySet: false, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", }; @@ -1164,6 +1538,7 @@ export class CodexAcpServer { } const availableCommands = await this.availableCommands.getAvailableCommands(sessionState); + await this.publishCurrentGoalBestEffort(sessionState, requestedSessionGeneration, true); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -1271,9 +1646,10 @@ export class CodexAcpServer { case "userMessage": return this.createUserMessageUpdates(item); case "hookPrompt": - case "subAgentActivity": case "sleep": return []; + case "subAgentActivity": + return [createSubAgentActivityUpdate(item, "completed", "tool_call")]; case "agentMessage": { return [{ sessionUpdate: "agent_message_chunk", @@ -1313,7 +1689,7 @@ export class CodexAcpServer { case "contextCompaction": return [createCompletedContextCompactionUpdate(item)]; case "plan": - return [this.createPlanUpdate(item)]; + return item.text.length > 0 ? [this.createPlanHistoryUpdate(item)] : []; } } @@ -1364,16 +1740,24 @@ export class CodexAcpServer { }; } - private createPlanUpdate( + private createPlanHistoryUpdate( item: ThreadItem & { type: "plan" } ): UpdateSessionEvent { - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `Plan:\n${item.text}`, - }, - }; + if (clientSupportsPlanUpdates(this.clientCapabilities)) { + return { + sessionUpdate: "plan_update", + plan: { + type: "markdown", + planId: item.id, + content: item.text, + }, + }; + } + return createAgentTextMessageChunk( + item.text, + item.id, + createCodexMessagePhaseMeta("final_answer"), + ); } private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { @@ -1507,7 +1891,6 @@ export class CodexAcpServer { cancelSignal, signal: abortController.signal, currentTurn: null, - outcome: null, requestCancel: () => { if (abortController.signal.aborted) { return; @@ -1566,54 +1949,6 @@ export class CodexAcpServer { await this.connection.notify(CODEX_STEER_APPLIED_METHOD, {sessionId, steerId}); } - private async steerPrompt(params: acp.PromptRequest): Promise { - const steerId = getCodexSteerId(params._meta); - if (!steerId) throw RequestError.invalidRequest("Missing Codex steer id"); - const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : ""; - if (firstText.startsWith("/")) { - throw RequestError.invalidRequest("Slash commands cannot steer an active Codex turn"); - } - - let activePrompt = this.activePrompts.get(params.sessionId); - if (!activePrompt) throw RequestError.invalidRequest("No active Codex turn to steer"); - if (!activePrompt.currentTurn) { - await this.pendingTurnStarts.get(params.sessionId)?.promise; - activePrompt = this.activePrompts.get(params.sessionId); - } - const turn = activePrompt?.currentTurn; - if (!activePrompt || !turn || activePrompt.signal.aborted) { - throw RequestError.invalidRequest("No active Codex turn to steer"); - } - - const pending = this.pendingSteers.get(params.sessionId) ?? new Map(); - if (pending.has(steerId)) { - throw RequestError.invalidRequest(`Duplicate Codex steer id: ${steerId}`); - } - pending.set(steerId, {activePrompt, turnId: turn.turnId}); - this.pendingSteers.set(params.sessionId, pending); - try { - const steeredTurnId = await this.runWithProcessCheck( - () => this.codexAcpClient.sendSteer(params, turn.turnId, steerId), - ); - if (steeredTurnId !== turn.turnId) { - throw RequestError.internalError( - undefined, - `Codex steered unexpected turn ${steeredTurnId}; expected ${turn.turnId}`, - ); - } - } catch (error) { - if (pending.get(steerId)?.activePrompt === activePrompt) { - pending.delete(steerId); - if (pending.size === 0) this.pendingSteers.delete(params.sessionId); - } - throw error; - } - if (!activePrompt.outcome) { - throw RequestError.internalError(undefined, "Active Codex prompt has no tracked outcome"); - } - return await activePrompt.outcome; - } - private cancelBeforeTurnStarted(activePrompt: ActivePrompt): Promise { return activePrompt.cancelSignal.then(() => { if (activePrompt.currentTurn === null) { @@ -1778,24 +2113,16 @@ export class CodexAcpServer { return startedTurn ?? {threadId: sessionState.sessionId, turnId}; } - async prompt(params: acp.PromptRequest, signal?: AbortSignal): Promise { - if (getCodexSteerId(params._meta)) { - return await this.steerPrompt(params); - } + async prompt( + params: acp.PromptRequest, + signal?: AbortSignal, + onTurnStarted?: () => void, + ): Promise { if (this.activePrompts.has(params.sessionId)) { throw RequestError.invalidRequest( "A Codex prompt is already active; use the advertised steer extension", ); } - const outcome = this.runPrompt(params, signal); - const activePrompt = this.activePrompts.get(params.sessionId); - if (activePrompt) { - activePrompt.outcome = outcome; - } - return await outcome; - } - - private async runPrompt(params: acp.PromptRequest, signal?: AbortSignal): Promise { logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, @@ -1804,12 +2131,24 @@ export class CodexAcpServer { sessionState.currentTurnId = null; sessionState.lastTokenUsage = null; const activePrompt = this.trackActivePrompt(params.sessionId); - const pendingTurnStart = this.createPendingTurnStart(); - this.pendingTurnStarts.set(params.sessionId, pendingTurnStart); + let pendingTurnStart: PendingTurnStart | null = null; + const ensurePendingTurnStart = (): PendingTurnStart => { + if (pendingTurnStart === null) { + pendingTurnStart = this.createPendingTurnStart(); + this.pendingTurnStarts.set(params.sessionId, pendingTurnStart); + } + return pendingTurnStart; + }; const disposePromptRequestCancellation = this.observePromptRequestCancellation(signal, sessionState, activePrompt); + let eventHandler: CodexEventHandler | null = null; try { - const eventHandler = new CodexEventHandler(this.connection, sessionState); + const promptEventHandler = new CodexEventHandler( + this.connection, + sessionState, + clientSupportsPlanUpdates(this.clientCapabilities), + ); + eventHandler = promptEventHandler; const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, activePrompt.signal); const elicitationHandler = new CodexElicitationHandler( this.connection, @@ -1821,7 +2160,7 @@ export class CodexAcpServer { async (event) => { await this.handleSteerAppliedNotification(params.sessionId, event, activePrompt); await elicitationHandler.handleNotification(event); - return eventHandler.handleNotification(event); + return promptEventHandler.handleNotification(event); }, approvalHandler, elicitationHandler); @@ -1831,6 +2170,9 @@ export class CodexAcpServer { } const commandPromise = this.availableCommands.tryHandleCommand(params.prompt, sessionState, { + onTurnStartPending: () => { + ensurePendingTurnStart(); + }, onTurnStarted: (turnId, threadId) => { const turn = {threadId, turnId}; activePrompt.currentTurn = turn; @@ -1839,7 +2181,20 @@ export class CodexAcpServer { return; } sessionState.currentTurnId = turnId; - pendingTurnStart.resolve(turnId); + pendingTurnStart?.resolve(turnId); + onTurnStarted?.(); + }, + setConfigOption: async (configId, value) => { + await this.applySessionConfigOption(sessionState, { + sessionId: sessionState.sessionId, + configId, + value, + }); + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "config_option_update", + configOptions: this.createSessionConfigOptions(sessionState), + }); }, }); void commandPromise.catch((err) => { @@ -1859,7 +2214,6 @@ export class CodexAcpServer { logger.log("Prompt handled by a command"); await this.codexAcpClient.waitForSessionNotifications(params.sessionId); if (commandResult.turnCompleted?.turn.status === "interrupted") { - await this.notifyConversationInterrupted(params.sessionId); return this.cancelledPromptResponse(sessionState); } const error = eventHandler.getFailure(); @@ -1898,14 +2252,13 @@ export class CodexAcpServer { sessionState.fastModeEnabled, sessionState.currentModelSupportsFast, ); - const collaborationMode = this.createPlanModeCollaborationMode(sessionState, modelId); + ensurePendingTurnStart(); const sendPromptPromise = this.runWithProcessCheck( () => this.codexAcpClient.sendPrompt( params, agentMode, modelId, serviceTier, - collaborationMode, disableSummary, sessionState.cwd, sessionState.additionalDirectories, @@ -1917,7 +2270,8 @@ export class CodexAcpServer { return; } sessionState.currentTurnId = turnId; - pendingTurnStart.resolve(turnId); + pendingTurnStart?.resolve(turnId); + onTurnStarted?.(); }, () => this.promptShouldStop(params.sessionId, activePrompt), )); @@ -1926,7 +2280,7 @@ export class CodexAcpServer { logger.error(`Prompt for cancelled session ${params.sessionId} failed after prompt returned`, err); } }); - const turnCompleted = await Promise.race([ + let turnCompleted = await Promise.race([ sendPromptPromise, activePrompt.closeSignal, this.cancelBeforeTurnStarted(activePrompt), @@ -1939,7 +2293,7 @@ export class CodexAcpServer { await this.codexAcpClient.waitForSessionNotifications(params.sessionId); if (turnCompleted.turn.status === "interrupted") { - await this.notifyConversationInterrupted(params.sessionId); + await eventHandler.flushPendingPlanUpdates(); return this.cancelledPromptResponse(sessionState); } @@ -1949,6 +2303,83 @@ export class CodexAcpServer { throw error; } + await eventHandler.flushPendingPlanUpdates(); + const completedPlan = eventHandler.takeCompletedPlan(); + if ( + completedPlan !== null + && sessionState.collaborationMode === PLAN_COLLABORATION_MODE + && !this.promptShouldStop(params.sessionId, activePrompt) + ) { + const approved = await this.requestPlanImplementationPermission( + sessionState, + completedPlan, + activePrompt.signal, + ); + if (this.promptShouldStop(params.sessionId, activePrompt)) { + return this.cancelledPromptResponse(sessionState); + } + if (approved && !this.promptShouldStop(params.sessionId, activePrompt)) { + await this.applyCollaborationModeChange(sessionState, DEFAULT_COLLABORATION_MODE); + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "config_option_update", + configOptions: this.createSessionConfigOptions(sessionState), + }); + + const implementationRequest: acp.PromptRequest = { + sessionId: params.sessionId, + prompt: [{type: "text", text: "Implement the approved plan."}], + }; + activePrompt.currentTurn = null; + const implementationPromise = this.runWithProcessCheck( + () => this.codexAcpClient.sendPrompt( + implementationRequest, + agentMode, + modelId, + serviceTier, + disableSummary, + sessionState.cwd, + sessionState.additionalDirectories, + (turnId) => { + const turn = {threadId: params.sessionId, turnId}; + activePrompt.currentTurn = turn; + if (this.promptShouldStop(params.sessionId, activePrompt)) { + this.interruptLateStartedTurn(turn); + return; + } + sessionState.currentTurnId = turnId; + }, + () => this.promptShouldStop(params.sessionId, activePrompt), + ), + ); + void implementationPromise.catch((err) => { + if (this.activePrompts.get(params.sessionId) !== activePrompt) { + logger.error(`Implementation turn for cancelled prompt ${params.sessionId} failed after prompt returned`, err); + } + }); + turnCompleted = await Promise.race([ + implementationPromise, + activePrompt.closeSignal, + this.cancelBeforeTurnStarted(activePrompt), + ]); + + if (turnCompleted === null) { + return this.cancelledPromptResponse(sessionState); + } + + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + if (turnCompleted.turn.status === "interrupted") { + await eventHandler.flushPendingPlanUpdates(); + return this.cancelledPromptResponse(sessionState); + } + + const implementationError = eventHandler.getFailure(); + if (implementationError) { + throw implementationError; + } + } + } + await this.publishFallbackSessionTitle( sessionState, this.createPromptFallbackTitle(params.prompt), @@ -1964,6 +2395,7 @@ export class CodexAcpServer { throw err; } finally { logger.log("Prompt completed", {sessionId: params.sessionId}); + await eventHandler?.dispose(); disposePromptRequestCancellation(); sessionState.currentTurnId = null; const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId); @@ -1975,6 +2407,65 @@ export class CodexAcpServer { } } + private async requestPlanImplementationPermission( + sessionState: SessionState, + plan: CompletedPlan, + cancellationSignal: AbortSignal, + ): Promise { + const toolCallId = `plan-review:${plan.itemId}`; + try { + const response = await this.connection.request( + acp.methods.client.session.requestPermission, + { + sessionId: sessionState.sessionId, + toolCall: { + toolCallId, + title: "Implement this plan?", + kind: "switch_mode", + status: "pending", + rawInput: {plan: plan.text}, + }, + options: [ + { + optionId: IMPLEMENT_PLAN_OPTION_ID, + name: "Yes, implement this plan", + kind: "allow_once", + }, + { + optionId: REVISE_PLAN_OPTION_ID, + name: "No, and tell Codex what to do differently", + kind: "reject_once", + }, + ], + _meta: { + codex: { + kind: "plan_review", + planItemId: plan.itemId, + }, + }, + }, + {cancellationSignal}, + ); + const approved = response.outcome.outcome === "selected" + && response.outcome.optionId === IMPLEMENT_PLAN_OPTION_ID; + await this.connection.notify(acp.methods.client.session.update, { + sessionId: sessionState.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: approved + ? "User approved the plan." + : "User kept the session in plan mode.", + }, + }); + return approved; + } catch (error) { + logger.error("Error requesting plan implementation permission", error); + return false; + } + } + private cancelledPromptResponse(sessionState: SessionState): acp.PromptResponse { return { stopReason: "cancelled", @@ -1983,16 +2474,6 @@ export class CodexAcpServer { }; } - private async notifyConversationInterrupted(sessionId: string): Promise { - if (this.sessionIsClosing(sessionId) || !this.sessions.has(sessionId)) { - return; - } - await this.connection.notify(acp.methods.client.session.update, { - sessionId, - update: createAgentTextMessageChunk("*Conversation interrupted*"), - }); - } - private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 93e15b6a..3d3e1825 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -38,6 +38,8 @@ import type { ThreadGoalClearedNotification, ThreadGoalClearParams, ThreadGoalClearResponse, + ThreadGoalGetParams, + ThreadGoalGetResponse, ThreadGoalSetParams, ThreadGoalSetResponse, ThreadForkParams, @@ -50,6 +52,7 @@ import type { ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadSettings, ThreadStartParams, ThreadStartResponse, ThreadUnsubscribeParams, @@ -61,6 +64,8 @@ import type { TurnInterruptResponse, TurnStartParams, TurnStartResponse, + TurnSteerParams, + TurnSteerResponse, CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, FileChangeRequestApprovalParams, @@ -69,8 +74,6 @@ import type { PermissionsRequestApprovalResponse, ItemCompletedNotification, } from "./app-server/v2"; -import type {TurnSteerParams} from "./app-server/v2/TurnSteerParams"; -import type {TurnSteerResponse} from "./app-server/v2/TurnSteerResponse"; export interface ApprovalHandler { handleCommandExecution(params: CommandExecutionRequestApprovalParams): Promise; @@ -155,6 +158,7 @@ export class CodexAppServerClient { private readonly threadStatusCaptures = new Map void>>(); private readonly threadGoalUpdateCaptures = new Map void>>(); private readonly threadGoalClearedCaptures = new Map void>>(); + private readonly threadSettings = new Map(); private readonly staleTurnIds = new Map>(); private turnCompletionTerminalError: Error | null = null; @@ -193,6 +197,9 @@ export class CodexAppServerClient { if (isThreadGoalClearedNotification(serverNotification)) { this.recordThreadGoalCleared(serverNotification.params); } + if (serverNotification.method === "thread/settings/updated") { + this.threadSettings.set(serverNotification.params.threadId, serverNotification.params.threadSettings); + } const routing = extractTurnRouting(serverNotification); if (this.handleStaleTurnNotification(serverNotification, routing)) { return; @@ -285,10 +292,6 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "turn/start", params: params }); } - async turnSteer(params: TurnSteerParams): Promise { - return await this.sendRequest({ method: "turn/steer", params }); - } - async runTurn(params: TurnStartParams, onTurnStarted?: (turnId: string) => void): Promise { const capturedCompletions: Array = []; const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => { @@ -342,6 +345,7 @@ export class CodexAppServerClient { params: ThreadGoalSetParams, onTurnStarted?: (turnId: string) => void, runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS, + onGoalSet?: (goal: ThreadGoal) => void, ): Promise { let goalTurnId: string | null = null; const capturedCompletions: Array = []; @@ -393,6 +397,7 @@ export class CodexAppServerClient { try { const goalSetResponse = await this.threadGoalSet(params); expectedGoal = goalSetResponse.goal; + onGoalSet?.(expectedGoal); if (capturedGoalUpdates.some(event => goalsMatch(event.goal, expectedGoal!))) { goalUpdateHandled = true; resolveGoalUpdateHandled(); @@ -527,6 +532,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "turn/interrupt", params: params }); } + async turnSteer(params: TurnSteerParams): Promise { + return await this.sendRequest({ method: "turn/steer", params: params }); + } + async reviewStart(params: ReviewStartParams): Promise { return await this.sendRequest({ method: "review/start", params: params }); } @@ -549,6 +558,14 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/fork", params: params }); } + getThreadSettings(threadId: string): ThreadSettings | undefined { + return this.threadSettings.get(threadId); + } + + async threadSettingsUpdate(params: ExperimentalThreadSettingsUpdateParams): Promise { + await this.connection.sendRequest("thread/settings/update", params); + } + async threadList(params: ThreadListParams): Promise { return await this.sendRequest({ method: "thread/list", params: params }); } @@ -577,6 +594,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/goal/set", params: params }); } + async threadGoalGet(params: ThreadGoalGetParams): Promise { + return await this.sendRequest({ method: "thread/goal/get", params: params }); + } + async threadGoalClear(params: ThreadGoalClearParams): Promise { return await this.sendRequest({ method: "thread/goal/clear", params: params }); } @@ -1013,6 +1034,18 @@ type DistributiveOmit = T extends any ? Omit : never; +export interface ExperimentalThreadSettingsUpdateParams { + threadId: string; + collaborationMode: { + mode: "default" | "plan"; + settings: { + model: string; + reasoning_effort: string | null; + developer_instructions: string | null; + }; + }; +} + type McpServerStartupSnapshot = { status: McpServerStartupState; error: string | null; diff --git a/src/CodexApprovalHandler.ts b/src/CodexApprovalHandler.ts index dd73b8ae..7c2e08a9 100644 --- a/src/CodexApprovalHandler.ts +++ b/src/CodexApprovalHandler.ts @@ -29,17 +29,28 @@ type FileChangeDecisionOption = { decision: FileChangeApprovalDecision; }; +type PermissionMetadata = { + version: 1; + changes: Array>; +}; + function permissionOption( optionId: string, name: string, kind: acp.PermissionOptionKind, codexMeta?: Record, + permission?: PermissionMetadata, ): acp.PermissionOption { return { optionId, name, kind, - ...(codexMeta ? { _meta: { codex: codexMeta } } : {}), + ...((codexMeta || permission) ? { + _meta: { + ...(permission ? {permission} : {}), + ...(codexMeta ? {codex: codexMeta} : {}), + }, + } : {}), }; } @@ -175,12 +186,14 @@ export class CodexApprovalHandler implements ApprovalHandler { "Allow for Session", "allow_always", { decision: "allowPermissionsForSession", permissions: params.permissions }, + this.permissionGrantMetadata(params.permissions, "session"), ), permissionOption( ApprovalOptionId.AllowPermissionsForTurn, "Allow Once", "allow_once", { decision: "allowPermissionsForTurn", permissions: params.permissions }, + this.permissionGrantMetadata(params.permissions, "turn"), ), permissionOption( ApprovalOptionId.RejectPermissions, @@ -265,6 +278,23 @@ export class CodexApprovalHandler implements ApprovalHandler { : "Allow for Session", "allow_always", { decision: "acceptForSession" }, + params.networkApprovalContext ? { + version: 1, + changes: [{ + type: "grant", + operation: "grant", + description: `Allow access to ${params.networkApprovalContext.host} for this session`, + lifetime: {scope: "session"}, + targets: [{ + type: "network", + matcher: { + type: "host", + host: params.networkApprovalContext.host, + protocol: params.networkApprovalContext.protocol, + }, + }], + }], + } : undefined, ), decision: "acceptForSession", }, @@ -280,6 +310,22 @@ export class CodexApprovalHandler implements ApprovalHandler { decision: "acceptWithExecpolicyAmendment", execpolicyAmendment: params.proposedExecpolicyAmendment, }, + { + version: 1, + changes: [{ + type: "policy_rule", + operation: "add", + ruleBehavior: "allow", + description: `Allow commands starting with ${params.proposedExecpolicyAmendment.join(" ")}`, + targets: [{ + type: "command", + matcher: { + type: "argv_prefix", + argv: params.proposedExecpolicyAmendment, + }, + }], + }], + }, ), decision: { acceptWithExecpolicyAmendment: { @@ -299,6 +345,24 @@ export class CodexApprovalHandler implements ApprovalHandler { decision: "applyNetworkPolicyAmendment", networkPolicyAmendment: amendment, }, + { + version: 1, + changes: [{ + type: "policy_rule", + operation: "add", + ruleBehavior: amendment.action, + description: amendment.action === "allow" + ? `Allow access to ${amendment.host}` + : `Block access to ${amendment.host}`, + targets: [{ + type: "network", + matcher: { + type: "host", + host: amendment.host, + }, + }], + }], + }, ), decision: { applyNetworkPolicyAmendment: { @@ -328,6 +392,20 @@ export class CodexApprovalHandler implements ApprovalHandler { params.grantRoot ? "Allow Root for Session" : "Allow for Session", "allow_always", { decision: "acceptForSession", grantRoot: params.grantRoot ?? null }, + params.grantRoot ? { + version: 1, + changes: [{ + type: "grant", + operation: "grant", + description: `Allow writes under ${params.grantRoot} for this session`, + lifetime: {scope: "session"}, + targets: [{ + type: "filesystem", + access: ["write"], + matcher: {type: "directory", path: params.grantRoot}, + }], + }], + } : undefined, ), decision: "acceptForSession", }, @@ -353,6 +431,85 @@ export class CodexApprovalHandler implements ApprovalHandler { }; } + private permissionGrantMetadata( + permissions: RequestPermissionProfile, + scope: "turn" | "session", + ): PermissionMetadata | undefined { + const changes: Array> = []; + const lifetime = {scope}; + const suffix = scope === "session" ? " for this session" : " for this turn"; + + if (permissions.network?.enabled !== null && permissions.network?.enabled !== undefined) { + const allowed = permissions.network.enabled; + changes.push({ + type: allowed ? "grant" : "policy_rule", + operation: allowed ? "grant" : "add", + ...(allowed ? {} : {ruleBehavior: "deny"}), + description: `${allowed ? "Allow" : "Deny"} network access${suffix}`, + lifetime, + targets: [{type: "network", matcher: {type: "any"}}], + }); + } + + const fileSystem = permissions.fileSystem; + for (const path of fileSystem?.read ?? []) { + changes.push(this.fileSystemGrantChange(path, "read", lifetime, suffix)); + } + for (const path of fileSystem?.write ?? []) { + changes.push(this.fileSystemGrantChange(path, "write", lifetime, suffix)); + } + for (const entry of fileSystem?.entries ?? []) { + const matcher = (() => { + switch (entry.path.type) { + case "path": + return {type: "exact_path", path: entry.path.path}; + case "glob_pattern": + return {type: "glob", pattern: entry.path.pattern}; + case "special": + return {type: "special", provider: "codex", value: entry.path.value}; + } + })(); + const pathDescription = entry.path.type === "path" + ? entry.path.path + : entry.path.type === "glob_pattern" ? entry.path.pattern : JSON.stringify(entry.path.value); + changes.push({ + type: entry.access === "deny" ? "policy_rule" : "grant", + operation: entry.access === "deny" ? "add" : "grant", + ...(entry.access === "deny" ? {ruleBehavior: "deny"} : {}), + description: entry.access === "deny" + ? `Deny filesystem access to ${pathDescription}${suffix}` + : `Allow ${entry.access} access to ${pathDescription}${suffix}`, + lifetime, + targets: [{ + type: "filesystem", + ...(entry.access === "deny" ? {} : {access: [entry.access]}), + matcher, + }], + }); + } + + return changes.length > 0 ? {version: 1, changes} : undefined; + } + + private fileSystemGrantChange( + path: string, + access: "read" | "write", + lifetime: {scope: "turn" | "session"}, + suffix: string, + ): Record { + return { + type: "grant", + operation: "grant", + description: `Allow ${access} access to ${path}${suffix}`, + lifetime, + targets: [{ + type: "filesystem", + access: [access], + matcher: {type: "exact_path", path}, + }], + }; + } + private networkPolicyAmendmentOptionId(index: number): string { return `${ApprovalOptionId.ApplyNetworkPolicyAmendment}:${index}`; } diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 56c58bea..285e0071 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -14,6 +14,11 @@ import type {RateLimitsMap} from "./RateLimitsMap"; import type {TokenCount} from "./TokenCount"; import {logger} from "./Logger"; import {createAgentTextMessageChunk} from "./ContentChunks"; +import { + COLLABORATION_MODE_CONFIG_ID, + DEFAULT_COLLABORATION_MODE, + PLAN_COLLABORATION_MODE, +} from "./CollaborationModeConfig"; type ParsedSlashCommand = { name: string; @@ -27,6 +32,7 @@ export type CommandHandleResult = export type CommandHandleOptions = { onTurnStartPending?: () => void; onTurnStarted?: (turnId: string, threadId: string) => void; + setConfigOption?: (configId: string, value: string) => Promise; }; export type LogoutHandler = () => void | Promise; @@ -114,6 +120,20 @@ export class CodexCommands { */ private getBuiltinCommands(): AvailableCommand[] { return [ + { + name: "plan", + description: "Turn plan mode on.", + input: null, + _meta: { + commandAction: { + kind: "setConfigOption", + configId: COLLABORATION_MODE_CONFIG_ID, + value: PLAN_COLLABORATION_MODE, + resetValue: DEFAULT_COLLABORATION_MODE, + presentation: "state", + }, + }, + }, { name: "mcp", description: "List configured Model Context Protocol (MCP) tools.", @@ -151,8 +171,14 @@ export class CodexCommands { }, { name: "goal", - description: "Set, pause, resume, or clear a task goal.", - input: { hint: "[|clear|pause|resume]" } + description: "Set a goal to keep pursuing.", + input: { hint: "[|clear|pause|resume]" }, + _meta: { + commandAction: { + kind: "prefixPrompt", + presentation: "state", + }, + }, }, { name: "logout", @@ -193,6 +219,17 @@ export class CodexCommands { const sessionId = sessionState.sessionId; switch (commandName) { + case "plan": { + if (command.rest.length > 0) { + await this.sendCommandUsageMessage(commandName, "no arguments", sessionId); + return { handled: true }; + } + const mode = sessionState.collaborationMode === PLAN_COLLABORATION_MODE + ? DEFAULT_COLLABORATION_MODE + : PLAN_COLLABORATION_MODE; + await options.setConfigOption?.(COLLABORATION_MODE_CONFIG_ID, mode); + return { handled: options.setConfigOption !== undefined }; + } case "compact": { await this.runWithProcessCheck(() => this.codexAcpClient.runCompact(sessionId)); return { handled: true }; diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index f08f6083..6034fa6b 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -3,7 +3,7 @@ import type { FuzzyFileSearchSessionUpdatedNotification, ServerNotification } from "./app-server"; -import type {SessionState, ThreadGoalSnapshot} from "./CodexAcpServer"; +import type {SessionState} from "./CodexAcpServer"; import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import { @@ -61,6 +61,7 @@ import { createFuzzyFileSearchComplete, createFuzzyFileSearchStartOrUpdate, createMcpToolCallUpdate, + createSubAgentActivityUpdate, createWebSearchCompleteUpdate, createWebSearchStartUpdate, fuzzyFileSearchToolCallId, @@ -74,18 +75,36 @@ import { createAgentTextMessageChunk, createAgentTextThoughtChunk, } from "./ContentChunks"; +import {sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot"; +import {logger} from "./Logger"; export { stripShellPrefix }; +export type CompletedPlan = { + itemId: string; + text: string; +}; + export class CodexEventHandler { + private static readonly PLAN_UPDATE_INTERVAL_MS = 150; + private readonly connection: AcpClientConnection; private readonly sessionState: SessionState; + private readonly supportsPlanUpdates: boolean; private failure: RequestError | null = null; + private completedPlan: CompletedPlan | null = null; private readonly activeFuzzyFileSearchSessions = new Set(); private readonly activeGuardianApprovalReviews = new Set(); private readonly activeImageGenerationItems = new Set(); private readonly emittedImageViewItems = new Set(); + private readonly planDeltaTextByItemId = new Map(); + private readonly pendingPlanItemIds = new Set(); + private readonly lastEmittedPlanTextByItemId = new Map(); + private readonly session: ACPSessionConnection; + private planUpdateTimer: ReturnType | null = null; + private planUpdateChain: Promise = Promise.resolve(); + private disposed = false; private readonly seenReasoningDeltaItemIds = new Set(); private readonly reasoningSummaryFilters = new Map(); private readonly terminalCommandIds = new Set(); @@ -93,29 +112,66 @@ export class CodexEventHandler { private proposedPlanMarkdown = ""; private proposedPlanTurnId: string | null = null; private readonly agentMessagePhases = new Map(); + private readonly activeSubAgentActivities = new Set(); - constructor(connection: AcpClientConnection, sessionState: SessionState) { + constructor( + connection: AcpClientConnection, + sessionState: SessionState, + supportsPlanUpdates = false, + ) { this.connection = connection; this.sessionState = sessionState; + this.supportsPlanUpdates = supportsPlanUpdates; + this.session = new ACPSessionConnection(connection, sessionState.sessionId); } getFailure(): RequestError | null { return this.failure; } + takeCompletedPlan(): CompletedPlan | null { + const plan = this.completedPlan; + this.completedPlan = null; + return plan; + } + async handleNotification(notification: ServerNotification) { if (notification.method === "account/rateLimits/updated") { await this.handleRateLimitsSnapshot(notification.params.rateLimits, true); return; } - const session = new ACPSessionConnection(this.connection, this.sessionState.sessionId); const updateEvent = await this.createUpdateEvent(notification); if (updateEvent) { - await session.update(updateEvent); + await this.session.update(updateEvent); } await this.emitExtNotification(notification); } + async flushPendingPlanUpdates(): Promise { + this.cancelPlanUpdateTimer(); + do { + const itemIds = [...this.pendingPlanItemIds]; + this.pendingPlanItemIds.clear(); + await Promise.all(itemIds.map(itemId => { + const text = this.planDeltaTextByItemId.get(itemId) ?? ""; + return text.length > 0 + ? this.enqueuePlanSnapshot(itemId, text) + : Promise.resolve(); + })); + await this.planUpdateChain; + } while (this.pendingPlanItemIds.size > 0); + } + + async dispose(): Promise { + if (this.disposed) return; + await this.flushPendingPlanUpdates(); + this.disposed = true; + this.cancelPlanUpdateTimer(); + this.pendingPlanItemIds.clear(); + this.planDeltaTextByItemId.clear(); + this.lastEmittedPlanTextByItemId.clear(); + } + private async createUpdateEvent(notification: ServerNotification): Promise { /* TODO split UpdateSessionEvent to improve completion @@ -127,6 +183,8 @@ export class CodexEventHandler { switch (notification.method) { case "item/agentMessage/delta": return await this.createTextEvent(notification.params); + case "item/plan/delta": + return this.createPlanDeltaEvent(notification.params); case "item/started": return await this.createItemEvent(notification.params); case "item/completed": @@ -139,6 +197,8 @@ export class CodexEventHandler { this.sessionState.currentTurnId = notification.params.turn.id; return null; case "turn/completed": + await this.flushPendingPlanUpdates(); + this.clearPlanTurnState(); this.sessionState.currentTurnId = null; return null; case "thread/tokenUsage/updated": @@ -239,7 +299,6 @@ export class CodexEventHandler { case "rawResponseItem/completed": case "rawResponse/completed": case "thread/started": - case "item/plan/delta": case "remoteControl/status/changed": case "app/list/updated": case "thread/settings/updated": @@ -259,10 +318,14 @@ export class CodexEventHandler { ); return; case "item/plan/delta": - await this.emitCodexProposedPlanDelta(notification.params); + if (!this.supportsPlanUpdates) { + await this.emitCodexProposedPlanDelta(notification.params); + } return; case "turn/completed": - await this.emitCodexProposedPlanCompleted(notification.params); + if (!this.supportsPlanUpdates) { + await this.emitCodexProposedPlanCompleted(notification.params); + } return; default: return; @@ -386,8 +449,9 @@ export class CodexEventHandler { } private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent | null { - const goalSnapshot = this.createThreadGoalSnapshot(event); - if (this.sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) { + this.sessionState.goalRevision += 1; + const goalSnapshot = toThreadGoalSnapshot(event.goal); + if (sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) { return null; } this.sessionState.currentGoal = goalSnapshot; @@ -398,6 +462,7 @@ export class CodexEventHandler { } private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent | null { + this.sessionState.goalRevision += 1; if (this.sessionState.currentGoal === null) { return null; } @@ -408,25 +473,6 @@ export class CodexEventHandler { }); } - private createThreadGoalSnapshot(event: ThreadGoalUpdatedNotification): ThreadGoalSnapshot { - return { - objective: event.goal.objective.trim(), - status: event.goal.status, - tokenBudget: event.goal.tokenBudget, - }; - } - - private sameThreadGoalSnapshot( - left: ThreadGoalSnapshot | null | undefined, - right: ThreadGoalSnapshot - ): boolean { - return left !== null - && left !== undefined - && left.objective === right.objective - && left.status === right.status - && left.tokenBudget === right.tokenBudget; - } - private createReasoningSummaryDeltaEvent(event: ReasoningSummaryTextDeltaNotification): UpdateSessionEvent | null { this.seenReasoningDeltaItemIds.add(event.itemId); let filter = this.reasoningSummaryFilters.get(event.itemId); @@ -443,6 +489,20 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(event.delta, event.itemId); } + private createPlanDeltaEvent(event: PlanDeltaNotification): null { + if (event.delta.length === 0) { + return null; + } + const text = this.planDeltaTextByItemId.get(event.itemId) ?? ""; + const updatedText = text + event.delta; + this.planDeltaTextByItemId.set(event.itemId, updatedText); + if (this.supportsPlanUpdates) { + this.pendingPlanItemIds.add(event.itemId); + this.schedulePlanUpdate(); + } + return null; + } + private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent { this.seenReasoningDeltaItemIds.add(event.itemId); const trailingText = this.finishReasoningSummaryFilter(event.itemId); @@ -495,6 +555,8 @@ export class CodexEventHandler { case "contextCompaction": return createContextCompactionStartUpdate(event.item); case "subAgentActivity": + this.activeSubAgentActivities.add(event.item.id); + return createSubAgentActivityUpdate(event.item, "in_progress", "tool_call"); case "sleep": case "userMessage": case "hookPrompt": @@ -550,17 +612,25 @@ export class CodexEventHandler { case "agentMessage": this.rememberAgentMessagePhase(event.item); return null; + case "plan": { + const deltaText = this.planDeltaTextByItemId.get(event.item.id) ?? ""; + return await this.createCompletedPlanEvent(event.item, deltaText); + } case "exitedReviewMode": return this.createExitedReviewModeEvent(event.item); case "contextCompaction": return createContextCompactionCompleteUpdate(event.item); //ignored types - case "subAgentActivity": + case "subAgentActivity": { + const sessionUpdate = this.activeSubAgentActivities.delete(event.item.id) + ? "tool_call_update" + : "tool_call"; + return createSubAgentActivityUpdate(event.item, "completed", sessionUpdate); + } case "sleep": case "userMessage": case "hookPrompt": case "enteredReviewMode": - case "plan": return null; } @@ -578,6 +648,80 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(text, item.id); } + private async createCompletedPlanEvent( + item: ThreadItem & { type: "plan" }, + deltaText: string, + ): Promise { + const text = item.text.length > 0 ? item.text : deltaText; + this.pendingPlanItemIds.delete(item.id); + if (this.pendingPlanItemIds.size === 0) { + this.cancelPlanUpdateTimer(); + } + this.planDeltaTextByItemId.delete(item.id); + if (text.length === 0) { + return null; + } + this.completedPlan = {itemId: item.id, text}; + if (this.supportsPlanUpdates) { + await this.enqueuePlanSnapshot(item.id, text); + return null; + } + return this.createPlanTextEvent(text, item.id); + } + + private schedulePlanUpdate(): void { + if (this.disposed || this.planUpdateTimer !== null) return; + this.planUpdateTimer = setTimeout(() => { + this.planUpdateTimer = null; + void this.flushPendingPlanUpdates().catch(error => { + logger.error("Failed to flush throttled plan updates", error); + }); + }, CodexEventHandler.PLAN_UPDATE_INTERVAL_MS); + } + + private cancelPlanUpdateTimer(): void { + if (this.planUpdateTimer === null) return; + clearTimeout(this.planUpdateTimer); + this.planUpdateTimer = null; + } + + private enqueuePlanSnapshot(itemId: string, text: string): Promise { + const send = async () => { + if (this.lastEmittedPlanTextByItemId.get(itemId) === text) return; + await this.session.update(this.createPlanUpdateEvent(text, itemId)); + this.lastEmittedPlanTextByItemId.set(itemId, text); + }; + const result = this.planUpdateChain.then(send); + this.planUpdateChain = result.catch(() => {}); + return result; + } + + private clearPlanTurnState(): void { + this.cancelPlanUpdateTimer(); + this.pendingPlanItemIds.clear(); + this.planDeltaTextByItemId.clear(); + this.lastEmittedPlanTextByItemId.clear(); + } + + private createPlanUpdateEvent(text: string, planId: string): UpdateSessionEvent { + return { + sessionUpdate: "plan_update", + plan: { + type: "markdown", + planId, + content: text, + }, + }; + } + + private createPlanTextEvent(text: string, messageId: string): UpdateSessionEvent { + return createAgentTextMessageChunk( + text, + messageId, + createCodexMessagePhaseMeta("final_answer"), + ); + } + private createExitedReviewModeEvent(item: ThreadItem & { type: "exitedReviewMode" }): UpdateSessionEvent | null { const text = item.review.trim(); if (text.length === 0) { diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index 20fb7ba9..63e88ef9 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -40,6 +40,7 @@ type GuardianApprovalReviewNotification = | ItemGuardianApprovalReviewCompletedNotification; type WebSearchItem = ThreadItem & { type: "webSearch" }; type CollabAgentToolCallItem = ThreadItem & { type: "collabAgentToolCall" }; +type SubAgentActivityItem = ThreadItem & { type: "subAgentActivity" }; type CommandExecutionItem = ThreadItem & { type: "commandExecution" }; type ContextCompactionItem = ThreadItem & { type: "contextCompaction" }; type AcpToolCallEvent = Extract; @@ -387,7 +388,7 @@ export function createWebSearchStartUpdate( kind: "search", title: formatWebSearchTitle(item), status: "in_progress", - rawInput, + rawInput: createWebSearchRawInput(item), }; } @@ -400,7 +401,16 @@ export function createWebSearchCompleteUpdate( toolCallId: item.id, title: formatWebSearchTitle(item), status: "completed", - rawInput, + rawInput: createWebSearchRawInput(item), + }; +} + +function createWebSearchRawInput(item: WebSearchItem): Record { + return { + type: item.type, + id: item.id, + query: item.query, + action: item.action, }; } @@ -414,6 +424,7 @@ export function createCollabAgentToolCallUpdate( title: item.tool, status: toAcpStatus(item.status), rawInput: createCollabAgentToolCallRawInput(item), + _meta: createCollabAgentToolCallMeta(item), }; } @@ -426,6 +437,7 @@ export function createCollabAgentToolCallCompleteUpdate( title: item.tool, status: toAcpStatus(item.status), rawInput: createCollabAgentToolCallRawInput(item), + _meta: createCollabAgentToolCallMeta(item), }; } @@ -435,10 +447,74 @@ function createCollabAgentToolCallRawInput(item: CollabAgentToolCallItem) { senderThreadId: item.senderThreadId, receiverThreadIds: item.receiverThreadIds, agentsStates: item.agentsStates, + model: item.model, + reasoningEffort: item.reasoningEffort, status: item.status, }; } +function createCollabAgentToolCallMeta(item: CollabAgentToolCallItem) { + return { + codex: { + collaboration: { + tool: item.tool, + senderThreadId: item.senderThreadId, + receiverThreadIds: item.receiverThreadIds, + }, + }, + }; +} + +export function createSubAgentActivityUpdate( + item: SubAgentActivityItem, + status: "in_progress" | "completed", + sessionUpdate: "tool_call" | "tool_call_update", +): UpdateSessionEvent { + const name = item.agentPath.split("/").filter(Boolean).at(-1) ?? "subagent"; + const title = formatSubAgentActivityTitle(item.kind, name); + const common = { + toolCallId: item.id, + status, + rawInput: { + agentThreadId: item.agentThreadId, + agentPath: item.agentPath, + activityKind: item.kind, + }, + _meta: { + codex: { + subagent: { + threadId: item.agentThreadId, + path: item.agentPath, + activity: item.kind, + }, + }, + }, + }; + if (sessionUpdate === "tool_call") { + return { + sessionUpdate, + title, + kind: "other", + ...common, + }; + } + return { + sessionUpdate, + ...common, + }; +} + +function formatSubAgentActivityTitle(kind: SubAgentActivityItem["kind"], name: string): string { + switch (kind) { + case "started": + return `Start subagent ${name}`; + case "interacted": + return `Interact with subagent ${name}`; + case "interrupted": + return `Interrupt subagent ${name}`; + } +} + export function formatWebSearchTitle(item: WebSearchItem): string { const action = item.action; if (!action) { diff --git a/src/CollaborationModeConfig.ts b/src/CollaborationModeConfig.ts new file mode 100644 index 00000000..b1e651e8 --- /dev/null +++ b/src/CollaborationModeConfig.ts @@ -0,0 +1,41 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {ReasoningEffort} from "./app-server"; +import type {ModeKind} from "./app-server/ModeKind"; +import {ModelId} from "./ModelId"; + +export const COLLABORATION_MODE_CONFIG_ID = "collaboration_mode"; +export const DEFAULT_COLLABORATION_MODE: ModeKind = "default"; +export const PLAN_COLLABORATION_MODE: ModeKind = "plan"; + +export function createCollaborationModeConfigOption(currentValue: ModeKind): acp.SessionConfigOption { + return { + id: COLLABORATION_MODE_CONFIG_ID, + name: "Collaboration mode", + description: "How Codex collaborates for subsequent turns", + category: "collaboration_mode", + type: "select", + currentValue, + options: [ + {value: DEFAULT_COLLABORATION_MODE, name: "Default"}, + {value: PLAN_COLLABORATION_MODE, name: "Plan", description: "Plan before making changes"}, + ], + }; +} + +export function parseCollaborationMode(value: unknown): ModeKind | null { + if (value === DEFAULT_COLLABORATION_MODE) return DEFAULT_COLLABORATION_MODE; + if (value === PLAN_COLLABORATION_MODE) return PLAN_COLLABORATION_MODE; + return null; +} + +export function createCodexCollaborationMode(mode: ModeKind, currentModelId: string) { + const modelId = ModelId.fromString(currentModelId); + return { + mode, + settings: { + model: modelId.model, + reasoning_effort: modelId.effort as ReasoningEffort | null, + developer_instructions: null, + }, + }; +} diff --git a/src/PlanCapabilities.ts b/src/PlanCapabilities.ts new file mode 100644 index 00000000..9f1903bc --- /dev/null +++ b/src/PlanCapabilities.ts @@ -0,0 +1,7 @@ +import type * as acp from "@agentclientprotocol/sdk"; + +export function clientSupportsPlanUpdates( + clientCapabilities?: acp.ClientCapabilities | null, +): boolean { + return clientCapabilities?.plan != null; +} diff --git a/src/PlanModeConfig.ts b/src/PlanModeConfig.ts deleted file mode 100644 index 6a645d11..00000000 --- a/src/PlanModeConfig.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {SessionConfigOption} from "@agentclientprotocol/sdk"; - -export const PLAN_MODE_CONFIG_ID = "plan-mode"; -export const PLAN_MODE_ON = "on"; -export const PLAN_MODE_OFF = "off"; - -const PLAN_MODE_DESCRIPTION = "Plan without modifying files; switch off to implement the approved plan"; - -export function createPlanModeConfigOption(planModeEnabled: boolean): SessionConfigOption { - return { - id: PLAN_MODE_CONFIG_ID, - name: "Plan mode", - description: PLAN_MODE_DESCRIPTION, - category: PLAN_MODE_CONFIG_ID, - type: "select", - currentValue: planModeEnabled ? PLAN_MODE_ON : PLAN_MODE_OFF, - options: [ - { - value: PLAN_MODE_OFF, - name: "Off", - description: "Implement changes normally", - }, - { - value: PLAN_MODE_ON, - name: "On", - description: PLAN_MODE_DESCRIPTION, - }, - ], - }; -} diff --git a/src/SteeringQueue.ts b/src/SteeringQueue.ts new file mode 100644 index 00000000..c1f553b1 --- /dev/null +++ b/src/SteeringQueue.ts @@ -0,0 +1,56 @@ +import type {SessionSteerRequest, SessionSteeringResponse} from "./AcpExtensions"; + +interface QueuedSteering { + params: SessionSteerRequest; + resolve: (response: SessionSteeringResponse) => void; + reject: (error: unknown) => void; +} + +/** + * Serialises steering requests for a single session. Callers add a request via + * enqueue(); a single consumer loop runs them one at a time, in arrival order, + * so two concurrent steers can never race to start rival turns. + */ +export class SteeringQueue { + private readonly pending: QueuedSteering[] = []; + private processing = false; + + constructor( + private readonly handle: (params: SessionSteerRequest) => Promise, + ) {} + + enqueue(params: SessionSteerRequest): Promise { + return new Promise((resolve, reject) => { + this.pending.push({params, resolve, reject}); + this.startConsumer(); + }); + } + + /** No request is queued and the consumer is not running. */ + get isIdle(): boolean { + return !this.processing && this.pending.length === 0; + } + + private startConsumer(): void { + if (this.processing) { + return; // consumer already draining the queue + } + this.processing = true; + void this.consume(); + } + + private async consume(): Promise { + try { + while (this.pending.length > 0) { + const next = this.pending.shift()!; + try { + next.resolve(await this.handle(next.params)); + } catch (error) { + next.reject(error); // one failed steer must not stall the rest + } + } + } finally { + this.processing = false; + } + } +} diff --git a/src/ThreadGoalSnapshot.ts b/src/ThreadGoalSnapshot.ts new file mode 100644 index 00000000..bb950af8 --- /dev/null +++ b/src/ThreadGoalSnapshot.ts @@ -0,0 +1,34 @@ +import {GOAL_CONTROL_METHOD} from "./AcpExtensions"; +import type {ThreadGoal} from "./app-server/v2"; + +export interface ThreadGoalSnapshot { + objective: string; + status: ThreadGoal["status"]; + tokenBudget: number | null; + timeUsedSeconds: number; + createdAt: number; + controlMethod: typeof GOAL_CONTROL_METHOD; +} + +export function toThreadGoalSnapshot(goal: ThreadGoal): ThreadGoalSnapshot { + return { + objective: goal.objective.trim(), + status: goal.status, + tokenBudget: goal.tokenBudget, + timeUsedSeconds: goal.timeUsedSeconds, + createdAt: goal.createdAt, + controlMethod: GOAL_CONTROL_METHOD, + }; +} + +export function sameThreadGoalSnapshot( + left: ThreadGoalSnapshot | null | undefined, + right: ThreadGoalSnapshot | null, +): boolean { + if (left === undefined) return false; + if (left === null || right === null) return left === right; + return left.objective === right.objective + && left.status === right.status + && left.tokenBudget === right.tokenBudget + && left.createdAt === right.createdAt; +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 94bd8b14..19d34b8f 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -16,7 +16,7 @@ import {AgentMode} from "../../AgentMode"; import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; -import {ACP_EXT_SESSION_RATE_LIMITS_METHOD} from "../../AcpExtensions"; +import {ACP_EXT_SESSION_RATE_LIMITS_METHOD, GOAL_CONTROL_METHOD} from "../../AcpExtensions"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -455,6 +455,61 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + it('restores collaboration mode for resumed and loaded sessions', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAppServerClient, "threadResume").mockImplementation(async ({threadId}) => { + mockFixture.sendServerNotification({ + method: "thread/settings/updated", + params: { + threadId, + threadSettings: { + collaborationMode: { + mode: "plan", + settings: {}, + }, + }, + }, + }); + return { + thread: {id: threadId}, + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + } as any; + }); + vi.spyOn(codexAppServerClient, "threadRead").mockImplementation(async ({threadId}) => ({ + thread: {id: threadId, turns: []}, + } as any)); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + const resumed = await codexAcpAgent.resumeSession({ + sessionId: "resume-id", + cwd: "/workspace", + }); + const loaded = await codexAcpAgent.loadSession({ + sessionId: "load-id", + cwd: "/workspace", + mcpServers: [], + }); + + expect(codexAcpAgent.getSessionState("resume-id").collaborationMode).toBe("plan"); + expect(codexAcpAgent.getSessionState("load-id").collaborationMode).toBe("plan"); + expect(resumed.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); + expect(loaded.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); + }); + it('uses configured model provider when resuming sessions without an explicit provider', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpClient = mockFixture.getCodexAcpClient(); @@ -1516,9 +1571,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { it('handles goal slash commands through Codex app server', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture(); const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet") - .mockResolvedValue({ - threadId: "session-id", - turn: createTurn("goal-turn-id", "completed"), + .mockImplementation(async (_params, _onTurnStarted, _runtimeEffectsGraceMs, onGoalSet) => { + onGoalSet?.(createThreadGoal()); + return { + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }; }); const goalClearSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalClear") .mockResolvedValue(undefined); @@ -1548,7 +1606,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(goalRunSpy).toHaveBeenNthCalledWith(2, { threadId: "session-id", status: "paused", - }); + }, undefined, undefined, expect.any(Function)); expect(goalRunSpy).toHaveBeenNthCalledWith(3, { threadId: "session-id", status: "active", @@ -1687,9 +1745,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { _meta: { codex: { goal: { - objective: "Ship the migration and keep tests green", - status: "active", - tokenBudget: null, + objective: "Ship the migration and keep tests green", + status: "active", + tokenBudget: null, + timeUsedSeconds: 0, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, @@ -2408,6 +2469,86 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); }); + it('controls an active goal through the out-of-band session extension', async () => { + const { mockFixture, sessionState } = setupPromptFixture(); + // @ts-expect-error - registering local session state for the extension request path + mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); + const pausedGoal = createThreadGoal({status: "paused", timeUsedSeconds: 12}); + const setStatusSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "setGoalStatus").mockResolvedValue(pausedGoal); + const clearGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "clearGoal").mockResolvedValue(undefined); + const getGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal"); + mockFixture.clearAcpConnectionDump(); + + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + sessionId: "session-id", + action: "pause", + })).resolves.toEqual({}); + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + sessionId: "session-id", + action: "clear", + })).resolves.toEqual({}); + + expect(setStatusSpy).toHaveBeenCalledWith("session-id", "paused"); + expect(clearGoalSpy).toHaveBeenCalledWith("session-id"); + expect(getGoalSpy).not.toHaveBeenCalled(); + const goalUpdates = mockFixture.getAcpConnectionEvents([]).filter(event => + event.method === "sessionUpdate" + && "args" in event + && event.args[0]?.update?.sessionUpdate === "session_info_update" + ); + expect(goalUpdates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + args: [expect.objectContaining({ + update: expect.objectContaining({ + _meta: {codex: {goal: expect.objectContaining({status: "paused"})}}, + }), + })], + }), + expect.objectContaining({ + args: [expect.objectContaining({ + update: expect.objectContaining({ + _meta: {codex: {goal: null}}, + }), + })], + }), + ])); + }); + + it('ignores an older goal refresh that completes after a newer refresh', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const sessionState = createTestSessionState({sessionId: "session-id"}); + // @ts-expect-error - registering local session state for the refresh race + codexAcpAgent.sessions.set("session-id", sessionState); + const staleGoal = createThreadGoal({objective: "stale", createdAt: 100}); + const currentGoal = createThreadGoal({objective: "current", createdAt: 200}); + const staleResponse = deferred(); + const currentResponse = deferred(); + const getGoal = vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal") + .mockReturnValueOnce(staleResponse.promise) + .mockReturnValueOnce(currentResponse.promise); + + // @ts-expect-error - exercising the private refresh interleaving directly + const stalePublish = codexAcpAgent.publishCurrentGoal(sessionState, 0, true); + await vi.waitFor(() => expect(getGoal).toHaveBeenCalledTimes(1)); + // @ts-expect-error - exercising the private refresh interleaving directly + const currentPublish = codexAcpAgent.publishCurrentGoal(sessionState, 0, true); + currentResponse.resolve(currentGoal); + await currentPublish; + staleResponse.resolve(staleGoal); + await stalePublish; + + expect(sessionState.currentGoal).toMatchObject({objective: "current", createdAt: 200}); + const goalUpdates = mockFixture.getAcpConnectionEvents([]).filter(event => + event.method === "sessionUpdate" + && event.args[0]?.update?.sessionUpdate === "session_info_update" + ); + expect(goalUpdates).toHaveLength(1); + expect(goalUpdates[0]?.args[0]?.update?._meta).toEqual({ + codex: {goal: expect.objectContaining({objective: "current", createdAt: 200})}, + }); + }); + it('suppresses the first routed goal notification after cancellation marks the turn stale', async () => { const { mockFixture } = setupPromptFixture(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); @@ -2708,12 +2849,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "session-1", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }) .mockResolvedValueOnce({ sessionId: "session-2", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); @@ -2772,6 +2915,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "openai-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "openai", additionalDirectories: [], }); @@ -2824,6 +2968,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "custom-provider-session", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); @@ -3373,6 +3518,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "session-id", currentModelId: ModelId.create(model.id, model.defaultReasoningEffort).toString(), models: [model], + collaborationMode: "default", additionalDirectories: [], }); vi.spyOn(codexAcpClient, "getRateLimits").mockResolvedValue({ diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index 205a3802..821f23bc 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -155,6 +155,27 @@ describe('Approval Events', () => { expect.objectContaining({ optionId: ApprovalOptionId.AcceptWithExecpolicyAmendment, kind: 'allow_always', + _meta: { + permission: { + version: 1, + changes: [{ + type: 'policy_rule', + operation: 'add', + ruleBehavior: 'allow', + description: 'Allow commands starting with npm install', + targets: [{ + type: 'command', + matcher: { + type: 'argv_prefix', + argv: proposedExecpolicyAmendment, + }, + }], + }], + }, + codex: expect.objectContaining({ + execpolicyAmendment: proposedExecpolicyAmendment, + }), + }, }) ); @@ -201,6 +222,27 @@ describe('Approval Events', () => { expect.objectContaining({ optionId, kind: 'allow_always', + _meta: { + permission: { + version: 1, + changes: [{ + type: 'policy_rule', + operation: 'add', + ruleBehavior: 'allow', + description: 'Allow access to registry.npmjs.org', + targets: [{ + type: 'network', + matcher: { + type: 'host', + host: 'registry.npmjs.org', + }, + }], + }], + }, + codex: expect.objectContaining({ + networkPolicyAmendment, + }), + }, }) ); @@ -389,6 +431,46 @@ describe('Approval Events', () => { await promptPromise; }); + it('should describe a session write-root grant with common permission metadata', async () => { + const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); + fixture.setPermissionResponse({ + outcome: { outcome: 'selected', optionId: ApprovalOptionId.AllowAlways } + }); + + const params: FileChangeRequestApprovalParams = { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + itemId: 'file-change-grant-root', + reason: 'Write generated files', + grantRoot: '/workspace/generated', + }; + + await fixture.sendServerRequest('item/fileChange/requestApproval', params); + + const request = fixture.getAcpConnectionEvents([])[0]!.args[0]; + expect(request.options.find((option: { optionId: string }) => option.optionId === ApprovalOptionId.AllowAlways)?._meta) + .toMatchObject({ + permission: { + version: 1, + changes: [{ + type: 'grant', + operation: 'grant', + description: 'Allow writes under /workspace/generated for this session', + lifetime: {scope: 'session'}, + targets: [{ + type: 'filesystem', + access: ['write'], + matcher: {type: 'directory', path: '/workspace/generated'}, + }], + }], + }, + }); + + completeTurn(); + await promptPromise; + }); + it('should return cancel when no handler registered', async () => { const params: FileChangeRequestApprovalParams = { threadId: 'non-existent-session', diff --git a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts index 9964d2d1..5ad2c72a 100644 --- a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts +++ b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts @@ -84,4 +84,30 @@ describe("CodexEventHandler - collab agent tool call events", () => { "data/collab-agent-tool-call-flow.json" ); }); + + it("maps live subagent activity to an ACP tool call", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "subAgentActivity", + id: "call-spawn-weather", + kind: "started", + agentThreadId: "thread-paris", + agentPath: "/root/weather_research", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(`${mockFixture.getAcpConnectionDump([])}\n`).toMatchFileSnapshot( + "data/subagent-activity-flow.json" + ); + }); }); diff --git a/src/__tests__/CodexACPAgent/data/approval-permissions-request.json b/src/__tests__/CodexACPAgent/data/approval-permissions-request.json index 99ce7a14..c5bf196b 100644 --- a/src/__tests__/CodexACPAgent/data/approval-permissions-request.json +++ b/src/__tests__/CodexACPAgent/data/approval-permissions-request.json @@ -47,6 +47,67 @@ "name": "Allow for Session", "kind": "allow_always", "_meta": { + "permission": { + "version": 1, + "changes": [ + { + "type": "grant", + "operation": "grant", + "description": "Allow network access for this session", + "lifetime": { + "scope": "session" + }, + "targets": [ + { + "type": "network", + "matcher": { + "type": "any" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow read access to /home/user/project for this session", + "lifetime": { + "scope": "session" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "read" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow write access to /home/user/project/tmp for this session", + "lifetime": { + "scope": "session" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "write" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project/tmp" + } + } + ] + } + ] + }, "codex": { "decision": "allowPermissionsForSession", "permissions": { @@ -71,6 +132,67 @@ "name": "Allow Once", "kind": "allow_once", "_meta": { + "permission": { + "version": 1, + "changes": [ + { + "type": "grant", + "operation": "grant", + "description": "Allow network access for this turn", + "lifetime": { + "scope": "turn" + }, + "targets": [ + { + "type": "network", + "matcher": { + "type": "any" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow read access to /home/user/project for this turn", + "lifetime": { + "scope": "turn" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "read" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow write access to /home/user/project/tmp for this turn", + "lifetime": { + "scope": "turn" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "write" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project/tmp" + } + } + ] + } + ] + }, "codex": { "decision": "allowPermissionsForTurn", "permissions": { diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 26e78cbb..d734fe73 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", @@ -49,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index 59987072..d6c15414 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", @@ -49,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { diff --git a/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json index 8e7ed13c..4391e5a9 100644 --- a/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json +++ b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json @@ -21,7 +21,20 @@ "message": "Checking weather" } }, + "model": null, + "reasoningEffort": null, "status": "inProgress" + }, + "_meta": { + "codex": { + "collaboration": { + "tool": "spawnAgent", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ] + } + } } } } @@ -49,7 +62,20 @@ "message": null } }, + "model": null, + "reasoningEffort": null, "status": "completed" + }, + "_meta": { + "codex": { + "collaboration": { + "tool": "spawnAgent", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ] + } + } } } } diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 3f7ceb1d..149b3d74 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -1,3 +1,112 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, + { + "name": "mcp", + "description": "List configured Model Context Protocol (MCP) tools.", + "input": null + }, + { + "name": "skills", + "description": "List available skills.", + "input": null + }, + { + "name": "status", + "description": "Display session configuration and token usage.", + "input": null + }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, + { + "name": "goal", + "description": "Set a goal to keep pursuing.", + "input": { + "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } + } + }, + { + "name": "logout", + "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", + "input": null + } + ] + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": { + "objective": "Keep the restored migration green", + "status": "paused", + "tokenBudget": null, + "timeUsedSeconds": 46, + "createdAt": 1710000000, + "controlMethod": "_codex/session/goal_control" + } + } + } + } + } + ] +} { "method": "sessionUpdate", "args": [ @@ -296,4 +405,33 @@ } } ] -} \ No newline at end of file +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "tool_call", + "title": "Start subagent test_audit", + "kind": "other", + "toolCallId": "item-subagent-1", + "status": "completed", + "rawInput": { + "agentThreadId": "thread-child-1", + "agentPath": "/root/test_audit", + "activityKind": "started" + }, + "_meta": { + "codex": { + "subagent": { + "threadId": "thread-child-1", + "path": "/root/test_audit", + "activity": "started" + } + } + } + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index 2598bffe..d942efff 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -1,3 +1,105 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-legacy", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, + { + "name": "mcp", + "description": "List configured Model Context Protocol (MCP) tools.", + "input": null + }, + { + "name": "skills", + "description": "List available skills.", + "input": null + }, + { + "name": "status", + "description": "Display session configuration and token usage.", + "input": null + }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, + { + "name": "goal", + "description": "Set a goal to keep pursuing.", + "input": { + "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } + } + }, + { + "name": "logout", + "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", + "input": null + } + ] + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-legacy", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": null + } + } + } + } + ] +} { "method": "sessionUpdate", "args": [ @@ -49,9 +151,15 @@ "sessionId": "session-legacy", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "item-plan-1", "content": { "type": "text", - "text": "Plan:\nInspect project files" + "text": "Inspect project files" + }, + "_meta": { + "codex": { + "phase": "final_answer" + } } } } @@ -206,4 +314,4 @@ } } ] -} \ No newline at end of file +} diff --git a/src/__tests__/CodexACPAgent/data/plan-checklist-update.json b/src/__tests__/CodexACPAgent/data/plan-checklist-update.json new file mode 100644 index 00000000..ab0367f1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-checklist-update.json @@ -0,0 +1,23 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "plan", + "entries": [ + { + "status": "completed", + "content": "Add the event mapping", + "priority": "medium" + }, + { + "status": "in_progress", + "content": "Verify it in Zed", + "priority": "medium" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json b/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json new file mode 100644 index 00000000..4642c754 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json @@ -0,0 +1,21 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-2", + "content": { + "type": "text", + "text": "### Fallback plan\n\n1. Use the completed item." + }, + "_meta": { + "codex": { + "phase": "final_answer" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json b/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json new file mode 100644 index 00000000..87a37988 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json @@ -0,0 +1,21 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-2", + "content": { + "type": "text", + "text": "### Buffered plan\n\n1. Use the buffered fallback." + }, + "_meta": { + "codex": { + "phase": "final_answer" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/plan-deltas.json b/src/__tests__/CodexACPAgent/data/plan-deltas.json new file mode 100644 index 00000000..e2036870 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-deltas.json @@ -0,0 +1,21 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-1", + "content": { + "type": "text", + "text": "Completed text should not duplicate the streamed plan." + }, + "_meta": { + "codex": { + "phase": "final_answer" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/subagent-activity-flow.json b/src/__tests__/CodexACPAgent/data/subagent-activity-flow.json new file mode 100644 index 00000000..e1b6749c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/subagent-activity-flow.json @@ -0,0 +1,29 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "title": "Start subagent weather_research", + "kind": "other", + "toolCallId": "call-spawn-weather", + "status": "completed", + "rawInput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_research", + "activityKind": "started" + }, + "_meta": { + "codex": { + "subagent": { + "threadId": "thread-paris", + "path": "/root/weather_research", + "activity": "started" + } + } + } + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json index c524584c..6008d56e 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -10,7 +10,10 @@ "goal": { "objective": "First task\nSecond task", "status": "budgetLimited", - "tokenBudget": 1000 + "tokenBudget": 1000, + "timeUsedSeconds": 30, + "createdAt": 1710000000, + "controlMethod": "_codex/session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json index bc14e9cb..ed17b6da 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -10,7 +10,10 @@ "goal": { "objective": "Ship the goal update", "status": "active", - "tokenBudget": null + "tokenBudget": null, + "timeUsedSeconds": 12, + "createdAt": 1710000000, + "controlMethod": "_codex/session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts index 51182971..6edc1956 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts @@ -35,7 +35,8 @@ describeE2E("E2E file approval tests", () => { it("does not apply rejected file edits", async () => { fixture.setPermissionResponder(createPermissionResponder("edit", ApprovalOptionId.RejectOnce)); const sessionId = await editFileDirectly(fixture, path.join(fixture.workspaceDir, generateFileNameForTest()), false); - expectPermissionRequests(fixture, sessionId, {edit: 1, execute: 0}); + expect(fixture.readPermissionRequests(sessionId, "edit").length).toBeGreaterThanOrEqual(1); + expect(fixture.readPermissionRequests(sessionId, "execute")).toHaveLength(0); }); }); diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts index e6555e0c..b1b69b75 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts @@ -2,7 +2,7 @@ import {afterEach, beforeEach, expect, it} from "vitest"; import {createAuthenticatedFixture, describeE2E, type SpawnedAgentFixture,} from "./acp-e2e-test-utils"; import {ModelId} from "../../../ModelId"; -const DEFAULT_MODEL_ID = ModelId.create("gpt-5.4-mini", "medium") +const DEFAULT_MODEL_ID = ModelId.create("gpt-5.6-sol", "medium") describeE2E("Models availability", () => { let fixture: SpawnedAgentFixture; diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index 32fbbdc3..d4edc609 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -10,7 +10,7 @@ import type {PermissionResponder} from "./permission-responders"; import type {LegacyNewSessionResponse} from "../../../AcpExtensions"; export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-5.2", "none"); -export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.4-mini", "low"); +export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.5", "low"); export interface TestSkill { readonly name: string; diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index 07d42444..6007d529 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -43,6 +43,7 @@ describe("Fast mode session config", () => { sessionId: "session-id", currentModelId: "fast-model[medium]", models: [fastModel, slowModel], + collaborationMode: "default", currentServiceTier, additionalDirectories: [], }); diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index fa77aa68..d1236ace 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -64,6 +64,7 @@ describe('CodexACPAgent - initialize', () => { codex: { steer: { version: 1, + method: "_session/steering", appliedNotification: "_codex/steerApplied", upstreamTurn: "same", configPolicy: "active", @@ -77,6 +78,11 @@ describe('CodexACPAgent - initialize', () => { }, }, authMethods: getCodexAuthMethods(), + _meta: { + steering: { + supported: true, + }, + }, }); }); @@ -101,7 +107,7 @@ describe('CodexACPAgent - initialize', () => { ])); }); - it('should retain the fork experimental app-server capability configuration', async () => { + it('enables experimental thread APIs used by fork and settings without requesting attestation', async () => { await agent.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index b6a03179..5f98eab6 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -196,6 +196,7 @@ describe("CodexACPAgent - list sessions", () => { defaultServiceTier: null, isDefault: true, }], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: ["/repo/extra"], }); diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index f8aea520..a64089e1 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createCodexMockTestFixture, createTestModel } from "../acp-test-utils"; -import type { Model, Thread } from "../../app-server/v2"; +import type { Model, Thread, ThreadGoal } from "../../app-server/v2"; describe("CodexACPAgent - loadSession", () => { it("should replay history during loadSession", async () => { @@ -165,6 +165,13 @@ describe("CodexACPAgent - loadSession", () => { type: "contextCompaction", id: "item-context-compaction-1", }, + { + type: "subAgentActivity", + id: "item-subagent-1", + kind: "started", + agentThreadId: "thread-child-1", + agentPath: "/root/test_audit", + }, ], }, ], @@ -190,6 +197,17 @@ describe("CodexACPAgent - loadSession", () => { codexAppServerClient.threadRead = vi.fn().mockResolvedValue({ thread: thread, }); + const goal: ThreadGoal = { + threadId: thread.id, + objective: "Keep the restored migration green", + status: "paused", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 46, + createdAt: 1710000000, + updatedAt: 1710000046, + }; + codexAppServerClient.threadGoalGet = vi.fn().mockResolvedValue({ goal }); await codexAcpAgent.initialize({ protocolVersion: 1 }); @@ -204,6 +222,7 @@ describe("CodexACPAgent - loadSession", () => { threadId: thread.id, includeTurns: true, }); + expect(codexAppServerClient.threadGoalGet).toHaveBeenCalledWith({ threadId: thread.id }); await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot( "data/load-session-history.json" ); diff --git a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts index 7d528a7e..94a92d71 100644 --- a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts @@ -8,20 +8,30 @@ import type {McpServerStdio} from "@agentclientprotocol/sdk"; import {startCodexConnection} from "../../CodexJsonRpcConnection"; import {createBaseTestFixture, removeDirectoryWithRetry, type TestFixture} from "../acp-test-utils"; -describe('MCP config merge across global config and ACP request', { timeout: 40_000 }, () => { +describe('MCP config merge across configured MCP servers and ACP request', { timeout: 40_000 }, () => { let codexHome: string; + let projectPath: string; let fixture: TestFixture; beforeEach(() => { vi.clearAllMocks(); - const configToml = ` + const globalConfig = ` [mcp_servers.shared-mcp] url = "https://example.com/mcp" `; + + const projectConfig = ` +[mcp_servers.project-mcp] +url = "https://example.com/mcp" +`; + codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-acp-mcp-merge-")); - fs.writeFileSync(path.join(codexHome, "config.toml"), configToml, "utf8"); + fs.writeFileSync(path.join(codexHome, "config.toml"), globalConfig, "utf8"); + projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "codex-acp-mcp-project-")); + fs.mkdirSync(path.join(projectPath, ".codex")); + fs.writeFileSync(path.join(projectPath, ".codex", "config.toml"), projectConfig, "utf8"); const codexConnection = startCodexConnection(undefined, { ...process.env, @@ -37,6 +47,54 @@ url = "https://example.com/mcp" afterEach(() => { vi.unstubAllEnvs(); removeDirectoryWithRetry(codexHome); + removeDirectoryWithRetry(projectPath); + }); + + it('should preserve the global url-based MCP when ACP passes a command-type MCP with the same name', async () => { + const codexAcpAgent = fixture.getCodexAcpAgent(); + await codexAcpAgent.initialize({protocolVersion: 1}); + + fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + + const conflictingMcp: McpServerStdio = { + name: "shared-mcp", + command: "./node_modules/.bin/mcp-hello-world", + args: ["example"], + env: [{name: "example", value: "example"}], + }; + + const newSessionResponse = await codexAcpAgent.newSession({ + cwd: "", + mcpServers: [conflictingMcp], + }); + fixture.clearAcpConnectionDump(); + + await codexAcpAgent.prompt({ + sessionId: newSessionResponse.sessionId, + prompt: [{type: "text", text: "/mcp"}], + }); + + const transportDump = fixture.getAcpConnectionDump([]); + expect(transportDump).contain("Configured MCP servers:"); + expect(transportDump).contain("- shared-mcp"); + }); + + it('should preserve a project url-based MCP when ACP passes a command-type MCP with the same name', async () => { + const codexAcpAgent = fixture.getCodexAcpAgent(); + await codexAcpAgent.initialize({protocolVersion: 1}); + fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + + const conflictingMcp = { + name: "project-mcp", + command: "./node_modules/.bin/mcp-hello-world", + args: ["example"], + env: [{name: "example", value: "example"}], + }; + + await expect(codexAcpAgent.newSession({ + cwd: projectPath, + mcpServers: [conflictingMcp], + })).resolves.toBeDefined(); }); it('should not filter the conflicting ACP MCP when config filtering is disabled', async () => { @@ -56,6 +114,8 @@ url = "https://example.com/mcp" await expect(codexAcpAgent.newSession({ cwd: "", mcpServers: [conflictingMcp], - })).rejects.toThrow("url is not supported for stdio"); + })).rejects.toMatchObject({ + data: expect.stringContaining("url is not supported for stdio"), + }); }); }); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 280410a3..f5344242 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -128,6 +128,7 @@ describe("Model filtering", () => { sessionId: "session-id", currentModelId: "gpt-5.2[medium]", models, + collaborationMode: "default", additionalDirectories: [], }); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); diff --git a/src/__tests__/CodexACPAgent/new-session-logout.test.ts b/src/__tests__/CodexACPAgent/new-session-logout.test.ts index c8603f13..01a7b429 100644 --- a/src/__tests__/CodexACPAgent/new-session-logout.test.ts +++ b/src/__tests__/CodexACPAgent/new-session-logout.test.ts @@ -41,6 +41,24 @@ describe("New session logout handling", () => { expect(logoutSpy).toHaveBeenCalledOnce(); }); + it("includes the global config path in reload configuration errors", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); + + const errorMessage = 'Internal error: "failed to reload config: filesystem path `/tmp` must be absolute, use `~/...`, or start with `:`"'; + vi.spyOn(codexAppServerClient, "threadStart").mockRejectedValue(new Error(errorMessage)); + + expect(logoutSpy).toHaveBeenCalledTimes(0); + await expect(codexAcpAgent.newSession({cwd: "", mcpServers: []})) + .rejects.toMatchObject({ + data: expect.stringContaining(`Check global and project .codex directories`), + }); + }); + it("refreshes OpenAI sessions when newSession error forces logout", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); @@ -64,6 +82,7 @@ describe("New session logout handling", () => { sessionId: "openai-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "openai", additionalDirectories: [], }) @@ -71,6 +90,7 @@ describe("New session logout handling", () => { sessionId: "custom-provider-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "custom-provider", additionalDirectories: [], }) diff --git a/src/__tests__/CodexACPAgent/plan-events.test.ts b/src/__tests__/CodexACPAgent/plan-events.test.ts new file mode 100644 index 00000000..aa22e89a --- /dev/null +++ b/src/__tests__/CodexACPAgent/plan-events.test.ts @@ -0,0 +1,343 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import type {ServerNotification} from "../../app-server"; +import {AgentMode} from "../../AgentMode"; +import type {SessionState} from "../../CodexAcpServer"; +import {CodexEventHandler} from "../../CodexEventHandler"; +import type {AcpClientConnection} from "../../ACPSessionConnection"; +import { + createCodexMockTestFixture, + createTestSessionState, + setupPromptAndSendNotifications, + type CodexMockTestFixture, +} from "../acp-test-utils"; + +describe("CodexEventHandler - plan events", () => { + let mockFixture: CodexMockTestFixture; + const sessionId = "test-session-id"; + + beforeEach(() => { + mockFixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const sessionState: SessionState = createTestSessionState({ + sessionId, + currentModelId: "model-id[effort]", + agentMode: AgentMode.DEFAULT_AGENT_MODE, + }); + + it("emits the authoritative completed plan after buffering deltas", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "plan", + id: "plan-1", + text: "", + }, + }, + }, + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-1", + delta: "### Implementation plan\n\n", + }, + }, + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-1", + delta: "1. Add the event mapping.\n2. Verify it.", + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-1", + text: "Completed text should not duplicate the streamed plan.", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-deltas.json", + ); + }); + + it("falls back to buffered deltas when the completed plan is empty", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-2", + delta: "### Buffered plan\n\n", + }, + }, + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-2", + delta: "1. Use the buffered fallback.", + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-2", + text: "", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-delta-fallback.json", + ); + }); + + it("emits the completed plan when no deltas streamed", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-2", + text: "### Fallback plan\n\n1. Use the completed item.", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-completed-fallback.json", + ); + }); + + it("keeps turn plan updates as ACP checklist updates", async () => { + const notifications: ServerNotification[] = [ + { + method: "turn/plan/updated", + params: { + threadId: sessionId, + turnId: "turn-1", + explanation: "Implement and verify the mapping.", + plan: [ + { + step: "Add the event mapping", + status: "completed", + }, + { + step: "Verify it in Zed", + status: "inProgress", + }, + ], + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-checklist-update.json", + ); + }); + + describe("plan update coalescing", () => { + function createHandler( + notify = vi.fn(async (_method: unknown, _params: unknown) => {}), + ) { + const connection = { + notify, + request: vi.fn(), + } as unknown as AcpClientConnection; + const handler = new CodexEventHandler(connection, sessionState, true); + const planUpdates = () => notify.mock.calls + .map(call => call[1] as {update?: {sessionUpdate?: string, plan?: {planId: string, content: string}}}) + .filter(params => params.update?.sessionUpdate === "plan_update") + .map(params => params.update!.plan!); + return {handler, planUpdates}; + } + + function planDelta(itemId: string, delta: string): ServerNotification { + return { + method: "item/plan/delta", + params: {threadId: sessionId, turnId: "turn-1", itemId, delta}, + }; + } + + function completedPlan(itemId: string, text: string): ServerNotification { + return { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: {type: "plan", id: itemId, text}, + }, + }; + } + + function completedTurn(status: "completed" | "interrupted"): ServerNotification { + return { + method: "turn/completed", + params: { + threadId: sessionId, + turn: { + id: "turn-1", + items: [], + itemsView: "notLoaded", + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }, + }; + } + + it("coalesces many small deltas and emits the complete final snapshot", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + let fullText = ""; + + for (let index = 0; index < 200; index += 1) { + const delta = `${index % 10}`; + fullText += delta; + await handler.handleNotification(planDelta("plan-many", delta)); + if (index % 10 === 9) { + await vi.advanceTimersByTimeAsync(25); + } + } + await handler.handleNotification(completedPlan("plan-many", fullText)); + + expect(planUpdates().length).toBeLessThan(20); + expect(planUpdates().length).toBeGreaterThan(1); + expect(planUpdates().at(-1)).toEqual({type: "markdown", planId: "plan-many", content: fullText}); + await handler.dispose(); + }); + + it.each(["completed", "interrupted"] as const)("flushes a pending snapshot when the turn is %s", async status => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-boundary", "full pending plan")); + + await handler.handleNotification(completedTurn(status)); + + expect(planUpdates()).toEqual([{type: "markdown", planId: "plan-boundary", content: "full pending plan"}]); + await vi.advanceTimersByTimeAsync(1_000); + expect(planUpdates()).toHaveLength(1); + await handler.dispose(); + }); + + it("does not duplicate an identical completed snapshot", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-same", "same text")); + await vi.advanceTimersByTimeAsync(150); + + await handler.handleNotification(completedPlan("plan-same", "same text")); + + expect(planUpdates()).toEqual([{type: "markdown", planId: "plan-same", content: "same text"}]); + await handler.dispose(); + }); + + it("serializes an in-flight throttled snapshot before the completed snapshot", async () => { + vi.useFakeTimers(); + let releaseFirstSend!: () => void; + let markFirstSendStarted!: () => void; + const firstSendStarted = new Promise(resolve => { + markFirstSendStarted = resolve; + }); + const firstSendReleased = new Promise(resolve => { + releaseFirstSend = resolve; + }); + let firstSend = true; + const notify = vi.fn(async (_method: unknown, _params: unknown) => { + if (!firstSend) return; + firstSend = false; + markFirstSendStarted(); + await firstSendReleased; + }); + const {handler, planUpdates} = createHandler(notify); + await handler.handleNotification(planDelta("plan-race", "partial")); + + await vi.advanceTimersByTimeAsync(150); + await firstSendStarted; + const completion = handler.handleNotification(completedPlan("plan-race", "partial and final")); + releaseFirstSend(); + await completion; + + expect(planUpdates().map(plan => plan.content)).toEqual(["partial", "partial and final"]); + await handler.dispose(); + }); + + it("flushes and cancels pending work when disposed", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-dispose", "last session snapshot")); + + await handler.dispose(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(planUpdates()).toEqual([ + {type: "markdown", planId: "plan-dispose", content: "last session snapshot"}, + ]); + }); + + it("keeps independently streamed plans separate", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-a", "A1")); + await handler.handleNotification(planDelta("plan-b", "B1")); + await handler.handleNotification(planDelta("plan-a", "A2")); + await handler.handleNotification(planDelta("plan-b", "B2")); + + await handler.handleNotification(completedTurn("completed")); + + expect(planUpdates()).toEqual([ + {type: "markdown", planId: "plan-a", content: "A1A2"}, + {type: "markdown", planId: "plan-b", content: "B1B2"}, + ]); + await handler.dispose(); + }); + }); +}); diff --git a/src/__tests__/CodexACPAgent/plan-mode-config.test.ts b/src/__tests__/CodexACPAgent/plan-mode-config.test.ts deleted file mode 100644 index 1f7d3c4a..00000000 --- a/src/__tests__/CodexACPAgent/plan-mode-config.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import {describe, expect, it, vi} from "vitest"; -import { - createCodexMockTestFixture, - createTestModel, - setupPromptTestSession, -} from "../acp-test-utils"; -import { - createPlanModeConfigOption, - PLAN_MODE_CONFIG_ID, - PLAN_MODE_OFF, - PLAN_MODE_ON, -} from "../../PlanModeConfig"; - -describe("Plan mode session config", () => { - async function createSession() { - const fixture = createCodexMockTestFixture(); - const codexAcpAgent = fixture.getCodexAcpAgent(); - const codexAcpClient = fixture.getCodexAcpClient(); - const model = createTestModel({id: "model-id"}); - - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); - vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); - vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ - sessionId: "session-id", - currentModelId: "model-id[medium]", - models: [model], - currentServiceTier: null, - additionalDirectories: [], - }); - - const response = await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); - return {codexAcpAgent, response}; - } - - it("returns the Plan mode config option defaulted to Off for new sessions", async () => { - const {response} = await createSession(); - - expect(response.configOptions).toContainEqual(createPlanModeConfigOption(false)); - }); - - it("toggles Plan mode through session config options", async () => { - const {codexAcpAgent} = await createSession(); - - const onResponse = await codexAcpAgent.setSessionConfigOption({ - sessionId: "session-id", - configId: PLAN_MODE_CONFIG_ID, - value: PLAN_MODE_ON, - }); - expect(onResponse.configOptions).toContainEqual(createPlanModeConfigOption(true)); - expect(codexAcpAgent.getSessionState("session-id").planModeEnabled).toBe(true); - expect(codexAcpAgent.getSessionState("session-id").planModeExplicitlySet).toBe(true); - - const offResponse = await codexAcpAgent.setSessionConfigOption({ - sessionId: "session-id", - configId: PLAN_MODE_CONFIG_ID, - value: PLAN_MODE_OFF, - }); - expect(offResponse.configOptions).toContainEqual(createPlanModeConfigOption(false)); - expect(codexAcpAgent.getSessionState("session-id").planModeEnabled).toBe(false); - expect(codexAcpAgent.getSessionState("session-id").planModeExplicitlySet).toBe(true); - }); - - it("rejects unknown Plan mode values", async () => { - const {codexAcpAgent} = await createSession(); - - await expect(codexAcpAgent.setSessionConfigOption({ - sessionId: "session-id", - configId: PLAN_MODE_CONFIG_ID, - value: "maybe", - })).rejects.toThrow(); - }); - - it("does not send collaborationMode before Plan mode is configured", async () => { - const {mockFixture, turnStartSpy} = setupPromptTestSession({ - sessionId: "session-id", - currentModelId: "model-id[high]", - }); - const codexAcpAgent = mockFixture.getCodexAcpAgent(); - - await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); - - const [turnStartParams] = turnStartSpy.mock.calls[0]!; - expect("collaborationMode" in turnStartParams).toBe(false); - }); - - it("sends Plan collaboration mode with the selected reasoning effort when Plan mode is enabled", async () => { - const {mockFixture, turnStartSpy} = setupPromptTestSession({ - sessionId: "session-id", - currentModelId: "model-id[high]", - planModeEnabled: true, - planModeExplicitlySet: true, - }); - const codexAcpAgent = mockFixture.getCodexAcpAgent(); - - await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); - - expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ - collaborationMode: { - mode: "plan", - settings: { - model: "model-id", - reasoning_effort: "high", - developer_instructions: null, - }, - }, - })); - }); - - it("sends Default collaboration mode after Plan mode is explicitly disabled", async () => { - const {mockFixture, turnStartSpy} = setupPromptTestSession({ - sessionId: "session-id", - currentModelId: "model-id[high]", - planModeEnabled: false, - planModeExplicitlySet: true, - }); - const codexAcpAgent = mockFixture.getCodexAcpAgent(); - - await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); - - expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ - collaborationMode: { - mode: "default", - settings: { - model: "model-id", - reasoning_effort: "high", - developer_instructions: null, - }, - }, - })); - }); -}); diff --git a/src/__tests__/CodexACPAgent/plan-review-events.test.ts b/src/__tests__/CodexACPAgent/plan-review-events.test.ts new file mode 100644 index 00000000..c31d2d85 --- /dev/null +++ b/src/__tests__/CodexACPAgent/plan-review-events.test.ts @@ -0,0 +1,211 @@ +import * as acp from "@agentclientprotocol/sdk"; +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {PLAN_COLLABORATION_MODE} from "../../CollaborationModeConfig"; +import { + createCodexMockTestFixture, + createTestSessionState, + type CodexMockTestFixture, +} from "../acp-test-utils"; + +type TurnCompletion = { + threadId: string; + turn: { + id: string; + items: never[]; + itemsView: "notLoaded"; + status: "completed"; + error: null; + startedAt: null; + completedAt: null; + durationMs: null; + }; +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return {promise, resolve}; +} + +describe("CodexACPAgent - plan review", () => { + let fixture: CodexMockTestFixture; + const sessionId = "plan-review-session"; + + beforeEach(() => { + fixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + async function startPlanPrompt(permissionOptionId: string | null) { + await fixture.getCodexAcpAgent().initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {plan: {}}, + }); + fixture.setPermissionResponse(permissionOptionId === null + ? {outcome: {outcome: "cancelled"}} + : {outcome: {outcome: "selected", optionId: permissionOptionId}}); + + const sessionState = createTestSessionState({ + sessionId, + collaborationMode: PLAN_COLLABORATION_MODE, + }); + vi.spyOn(fixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + + const planTurn = deferred(); + const implementationTurn = deferred(); + const turnStart = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValueOnce({ + turn: { + id: "plan-turn", + items: [], + itemsView: "notLoaded", + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }) + .mockResolvedValueOnce({ + turn: { + id: "implementation-turn", + items: [], + itemsView: "notLoaded", + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + vi.spyOn(fixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockImplementation((_threadId, turnId) => turnId === "plan-turn" + ? planTurn.promise + : implementationTurn.promise); + + const promptPromise = fixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{type: "text", text: "Plan the change"}], + }); + await vi.waitFor(() => expect(turnStart).toHaveBeenCalledTimes(1)); + + fixture.sendServerNotification({ + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "plan-turn", + itemId: "plan-item", + delta: "# Implementation plan\n\n1. Make the change.", + }, + }); + fixture.sendServerNotification({ + method: "item/completed", + params: { + threadId: sessionId, + turnId: "plan-turn", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-item", + text: "# Implementation plan\n\n1. Make the change.", + }, + }, + }); + planTurn.resolve({ + threadId: sessionId, + turn: { + id: "plan-turn", + items: [], + itemsView: "notLoaded", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + + return {promptPromise, sessionState, turnStart, implementationTurn}; + } + + it("requests plan permission and starts one implementation turn when approved", async () => { + const {promptPromise, sessionState, turnStart, implementationTurn} = await startPlanPrompt("implement_plan"); + + await vi.waitFor(() => expect(turnStart).toHaveBeenCalledTimes(2)); + expect(turnStart.mock.calls[1]![0]).toMatchObject({ + threadId: sessionId, + input: [{type: "text", text: "Implement the approved plan."}], + }); + + const events = fixture.getAcpConnectionEvents([]); + expect(events).toContainEqual({ + method: "requestPermission", + args: [expect.objectContaining({ + sessionId, + toolCall: expect.objectContaining({ + toolCallId: "plan-review:plan-item", + title: "Implement this plan?", + kind: "switch_mode", + rawInput: {plan: "# Implementation plan\n\n1. Make the change."}, + }), + options: [ + {optionId: "implement_plan", name: "Yes, implement this plan", kind: "allow_once"}, + {optionId: "revise_plan", name: "No, and tell Codex what to do differently", kind: "reject_once"}, + ], + })], + }); + expect(events).toContainEqual({ + method: "sessionUpdate", + args: [{ + sessionId, + update: { + sessionUpdate: "plan_update", + plan: { + type: "markdown", + planId: "plan-item", + content: "# Implementation plan\n\n1. Make the change.", + }, + }, + }], + }); + const finalPlanUpdateIndex = events.reduce((lastIndex, event, index) => + event.method === "sessionUpdate" + && (event.args[0] as {update?: {sessionUpdate?: string}}).update?.sessionUpdate === "plan_update" + ? index + : lastIndex, + -1); + const permissionIndex = events.findIndex(event => event.method === "requestPermission"); + expect(finalPlanUpdateIndex).toBeGreaterThanOrEqual(0); + expect(permissionIndex).toBeGreaterThan(finalPlanUpdateIndex); + expect(sessionState.collaborationMode).toBe("default"); + + implementationTurn.resolve({ + threadId: sessionId, + turn: { + id: "implementation-turn", + items: [], + itemsView: "notLoaded", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(turnStart).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["revise_plan", "rejected"], + [null, "cancelled"], + ])("keeps plan mode and does not implement when review is %s", async (optionId, _description) => { + const {promptPromise, sessionState, turnStart} = await startPlanPrompt(optionId); + + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(turnStart).toHaveBeenCalledTimes(1); + expect(sessionState.collaborationMode).toBe(PLAN_COLLABORATION_MODE); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-close.test.ts b/src/__tests__/CodexACPAgent/session-close.test.ts index 63ddd085..1f463df7 100644 --- a/src/__tests__/CodexACPAgent/session-close.test.ts +++ b/src/__tests__/CodexACPAgent/session-close.test.ts @@ -86,7 +86,6 @@ describe("ACP session close", () => { const requestMethods = fixture.getCodexConnectionEvents([]) .flatMap(event => event.eventType === "request" ? [event.method] : []); expect(requestMethods).toEqual(["thread/unsubscribe"]); - expect(fixture.getAcpConnectionDump([])).not.toContain("Conversation interrupted"); expect(() => codexAcpAgent.getSessionState(sessionId)).toThrow(`Session ${sessionId} not found`); fixture.clearCodexConnectionDump(); @@ -466,6 +465,7 @@ async function createSession(options: { sessionId, currentModelId: "model-id[medium]", models: [model], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }); @@ -522,6 +522,7 @@ function createSessionMetadata(): SessionMetadata { sessionId, currentModelId: "model-id[medium]", models: [createTestModel()], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }; diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index e2cb1f0b..88c0c9ec 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -7,7 +7,10 @@ import { } from "../../ModelConfigOption"; import type {Model, ReasoningEffortOption} from "../../app-server/v2"; import {LEGACY_SET_SESSION_MODEL_METHOD} from "../../AcpExtensions"; -import {PLAN_MODE_CONFIG_ID} from "../../PlanModeConfig"; +import { + COLLABORATION_MODE_CONFIG_ID, + PLAN_COLLABORATION_MODE, +} from "../../CollaborationModeConfig"; const lowEffort: ReasoningEffortOption = {reasoningEffort: "low", description: "Fast"}; const mediumEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; @@ -44,20 +47,21 @@ async function createSession(currentModelId: string, availableModels: Array { - it("exposes mode, model, reasoning_effort, fast-mode and plan-mode in the new session response", async () => { + it("exposes mode, model, reasoning_effort, fast-mode and collaboration mode", async () => { const {fast, slow} = buildModels(); const {response} = await createSession("fast-model[medium]", [fast, slow]); const ids = response.configOptions?.map(o => o.id); - expect(ids).toEqual([MODE_CONFIG_ID, MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, "fast-mode", PLAN_MODE_CONFIG_ID]); + expect(ids).toEqual([MODE_CONFIG_ID, COLLABORATION_MODE_CONFIG_ID, MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, "fast-mode"]); const modelOption = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); expect(modelOption).toMatchObject({ @@ -99,7 +103,7 @@ describe("Session config options", () => { const {codexAcpAgent, response} = await createSession("custom-model[high]", [fast, slow]); const ids = response.configOptions?.map(o => o.id); - expect(ids).toEqual([MODE_CONFIG_ID, MODEL_CONFIG_ID, PLAN_MODE_CONFIG_ID]); + expect(ids).toEqual([MODE_CONFIG_ID, COLLABORATION_MODE_CONFIG_ID, MODEL_CONFIG_ID]); const modelOption = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); expect(modelOption).toMatchObject({ @@ -153,6 +157,80 @@ describe("Session config options", () => { expect((modeOption as any).currentValue).toBe(AgentMode.ReadOnly.id); }); + it("changes collaboration mode without starting a model turn", async () => { + const {fast} = buildModels(); + const {codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); + const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + + const result = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: COLLABORATION_MODE_CONFIG_ID, + value: PLAN_COLLABORATION_MODE, + }); + + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "plan"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("plan"); + expect(result.configOptions?.find(o => o.id === COLLABORATION_MODE_CONFIG_ID)).toMatchObject({currentValue: "plan"}); + }); + + it("toggles collaboration mode with /plan without starting a model turn", async () => { + const {fast} = buildModels(); + const {fixture, codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); + const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + const turnStart = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart"); + + const enabledResponse = await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "/plan"}], + }); + + expect(enabledResponse.stopReason).toBe("end_turn"); + expect(turnStart).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "plan"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("plan"); + + const disabledResponse = await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "/plan"}], + }); + + expect(disabledResponse.stopReason).toBe("end_turn"); + expect(turnStart).not.toHaveBeenCalled(); + expect(update).toHaveBeenLastCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "default"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("default"); + expect(fixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ + method: "sessionUpdate", + args: [expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({id: COLLABORATION_MODE_CONFIG_ID, currentValue: "plan"}), + ]), + }), + })], + })); + expect(fixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ + method: "sessionUpdate", + args: [expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({id: COLLABORATION_MODE_CONFIG_ID, currentValue: "default"}), + ]), + }), + })], + })); + }); + it("changes the model and keeps the current reasoning effort when supported", async () => { const {fast, slow} = buildModels(); const {codexAcpAgent} = await createSession("fast-model[medium]", [fast, slow]); @@ -204,6 +282,7 @@ describe("Session config options", () => { sessionId: "session-id", currentModelId: "fast-model[medium]", models: [fast], + collaborationMode: "default", additionalDirectories: [], }); await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); diff --git a/src/__tests__/CodexACPAgent/session-delete.test.ts b/src/__tests__/CodexACPAgent/session-delete.test.ts index cc8285e6..ba0207ed 100644 --- a/src/__tests__/CodexACPAgent/session-delete.test.ts +++ b/src/__tests__/CodexACPAgent/session-delete.test.ts @@ -128,6 +128,7 @@ async function createSession(): Promise<{ sessionId, currentModelId: "model-id[medium]", models: [model], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }); diff --git a/src/__tests__/CodexACPAgent/session-fork.test.ts b/src/__tests__/CodexACPAgent/session-fork.test.ts index e8b4089d..7a0de08a 100644 --- a/src/__tests__/CodexACPAgent/session-fork.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork.test.ts @@ -98,6 +98,7 @@ describe("ACP session fork", () => { sessionId: "child-session-id", currentModelId: "model-id[medium]", models: [model], + collaborationMode: "default", modelProvider: "custom-provider", currentServiceTier: null, additionalDirectories: ["/workspace/extra"], diff --git a/src/__tests__/CodexACPAgent/steer-events.test.ts b/src/__tests__/CodexACPAgent/steer-events.test.ts new file mode 100644 index 00000000..c13a719b --- /dev/null +++ b/src/__tests__/CodexACPAgent/steer-events.test.ts @@ -0,0 +1,234 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import type * as acp from "@agentclientprotocol/sdk"; +import {RequestError} from "@agentclientprotocol/sdk"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {SessionState} from "../../CodexAcpServer"; +import type {TurnCompletedNotification} from "../../app-server/v2"; +import {SESSION_STEERING_METHOD} from "../../AcpExtensions"; + +function createTurn(id: string, status: "inProgress" | "completed" | "interrupted") { + return { + id, + items: [], + itemsView: "notLoaded" as const, + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; +} + +function deferred(): {promise: Promise, resolve: (value: T) => void} { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} + +/** + * Drives a prompt to the point where a turn is active (in progress) and paused + * on turn completion, so a steer can be injected mid-turn. + */ +function startActiveTurn(sessionOverrides?: Partial) { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(sessionOverrides); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart").mockResolvedValue({ + turn: createTurn("turn-id", "inProgress"), + }); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + return {mockFixture, sessionState, turnCompleted}; +} + +describe('_session/steering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reports injected when the input joins the active turn', async () => { + const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer") + .mockResolvedValue({turnId: "turn-id"}); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "long running prompt"}], + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "also keep backward compatibility"}], + })).resolves.toEqual({outcome: "injected"}); + + expect(turnSteerSpy).toHaveBeenCalledWith({ + threadId: "session-id", + expectedTurnId: "turn-id", + input: [{type: "text", text: "also keep backward compatibility", text_elements: []}], + }); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + }); + + it('starts a new turn when no turn is active', async () => { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + const turnStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValue({turn: createTurn("new-turn-id", "inProgress")}); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "too late for the previous turn"}], + })).resolves.toEqual({outcome: "startedNewTurn"}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + input: [{type: "text", text: "too late for the previous turn", text_elements: []}], + })); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('starts a new turn when Codex reports that the tracked turn is no longer active', async () => { + const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); + const nextTurnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValueOnce({turn: createTurn("turn-id", "inProgress")}) + .mockResolvedValueOnce({turn: createTurn("new-turn-id", "inProgress")}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValueOnce(turnCompleted.promise) + .mockReturnValueOnce(nextTurnCompleted.promise); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer").mockImplementation(async () => { + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + throw Object.assign(new Error("Internal error"), { + data: {details: "no active turn to steer"}, + }); + }); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "long running prompt"}], + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "racing follow-up"}], + })).resolves.toEqual({outcome: "startedNewTurn"}); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(sessionState.currentTurnId).toBe("new-turn-id"); + + nextTurnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('serializes concurrent late steering requests without dropping either prompt', async () => { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValue({turn: createTurn("new-turn-id", "inProgress")}); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer") + .mockResolvedValue({turnId: "new-turn-id"}); + + const firstRequest = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "first late follow-up"}], + }); + const secondRequest = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "second late follow-up"}], + }); + + await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual([ + {outcome: "startedNewTurn"}, + {outcome: "injected"}, + ]); + expect(turnSteerSpy).toHaveBeenCalledWith({ + threadId: "session-id", + expectedTurnId: "new-turn-id", + input: [{type: "text", text: "second late follow-up", text_elements: []}], + }); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('reports failed instead of throwing when steering hits an unexpected error', async () => { + const mockFixture = createCodexMockTestFixture(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockImplementation(() => { + throw new Error("unexpected boom"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "keep the agent alive"}], + })).resolves.toEqual({outcome: "failed"}); + }); + + it('rejects malformed steer params', async () => { + const mockFixture = createCodexMockTestFixture(); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + })).rejects.toThrow(RequestError); + }); + + it('rejects image input when the model does not support it', async () => { + const {mockFixture} = startActiveTurn({supportedInputModalities: ["text"]}); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer"); + + const image: acp.ContentBlock = { + type: "image", + mimeType: "image/png", + data: "abc123", + }; + + const error = await mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [image], + }).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(RequestError); + expect((error as RequestError).data).toContain("does not support image input"); + expect(turnSteerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/steer.test.ts b/src/__tests__/CodexACPAgent/steer.test.ts index 411d8e68..73285c57 100644 --- a/src/__tests__/CodexACPAgent/steer.test.ts +++ b/src/__tests__/CodexACPAgent/steer.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from "vitest"; -import {CODEX_STEER_APPLIED_METHOD} from "../../AcpExtensions"; +import {CODEX_STEER_APPLIED_METHOD, SESSION_STEERING_METHOD} from "../../AcpExtensions"; import {setupPromptTestSession} from "../acp-test-utils"; import type {TurnCompletedNotification} from "../../app-server/v2"; @@ -28,10 +28,10 @@ describe("CodexACPAgent - steer", () => { }); await vi.waitFor(() => expect(turnStartSpy).toHaveBeenCalledOnce()); - const steerPrompt = mockFixture.getCodexAcpAgent().prompt({ + const steerPrompt = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { sessionId: sessionState.sessionId, prompt: [{type: "text", text: "change direction"}], - _meta: {codex: {steer: {id: "steer-1"}}}, + steerId: "steer-1", }); await vi.waitFor(() => expect(turnSteerSpy).toHaveBeenCalledWith({ threadId: sessionState.sessionId, @@ -83,7 +83,7 @@ describe("CodexACPAgent - steer", () => { }, }); await expect(originalPrompt).resolves.toMatchObject({stopReason: "end_turn"}); - await expect(steerPrompt).resolves.toMatchObject({stopReason: "end_turn"}); + await expect(steerPrompt).resolves.toEqual({outcome: "injected"}); }); it("rejects an unmarked concurrent prompt instead of starting an ambiguous turn", async () => { diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index 74fb6b97..acc5defb 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -131,12 +131,55 @@ describe("CodexEventHandler - thread goal events", () => { objective: "Ship the goal update", status: "active", tokenBudget: null, + timeUsedSeconds: 12, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, }); }); + it("should publish a replacement goal with the same contents and a different creation time", async () => { + const firstGoal: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: null, + goal: { + threadId: sessionId, + objective: "Ship the goal update", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1710000000, + updatedAt: 1710000000, + }, + }, + }; + const replacementGoal: ServerNotification = { + ...firstGoal, + params: { + ...firstGoal.params, + goal: { + ...firstGoal.params.goal, + createdAt: 1710000100, + updatedAt: 1710000100, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [firstGoal, replacementGoal]); + + const events = mockFixture.getAcpConnectionEvents([]); + expect(events).toHaveLength(2); + expect(events.map(event => event.args[0].update._meta?.codex?.goal?.createdAt)).toEqual([ + 1710000000, + 1710000100, + ]); + }); + it("should not append completed goal updates to preceding agent text", async () => { const goalCompletedNotification: ServerNotification = { method: "thread/goal/updated", @@ -192,6 +235,9 @@ describe("CodexEventHandler - thread goal events", () => { objective: "tell me a joke", status: "complete", tokenBudget: null, + timeUsedSeconds: 12, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, diff --git a/src/__tests__/SteeringQueue.test.ts b/src/__tests__/SteeringQueue.test.ts new file mode 100644 index 00000000..54b5e63b --- /dev/null +++ b/src/__tests__/SteeringQueue.test.ts @@ -0,0 +1,115 @@ +import {describe, expect, it} from "vitest"; +import type {SessionSteerRequest, SessionSteeringResponse} from "../AcpExtensions"; +import {SteeringQueue} from "../SteeringQueue"; + +function request(text: string): SessionSteerRequest { + return {sessionId: "session-id", prompt: [{type: "text", text}]}; +} + +function deferred(): {promise: Promise, resolve: (value: T) => void, reject: (error: unknown) => void} { + let resolve: (value: T) => void = () => {}; + let reject: (error: unknown) => void = () => {}; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return {promise, resolve, reject}; +} + +describe("SteeringQueue", () => { + it("runs enqueued requests one at a time in arrival order", async () => { + const order: string[] = []; + const queue = new SteeringQueue(async (params) => { + const text = (params.prompt[0] as {text: string}).text; + order.push(`start:${text}`); + await Promise.resolve(); + order.push(`end:${text}`); + return {outcome: "injected"}; + }); + + await Promise.all([ + queue.enqueue(request("a")), + queue.enqueue(request("b")), + queue.enqueue(request("c")), + ]); + + // Each request fully completes before the next one starts. + expect(order).toEqual([ + "start:a", "end:a", + "start:b", "end:b", + "start:c", "end:c", + ]); + }); + + it("never overlaps two handlers", async () => { + let active = 0; + let maxActive = 0; + const queue = new SteeringQueue(async () => { + active++; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active--; + return {outcome: "injected"}; + }); + + await Promise.all(Array.from({length: 5}, (_, i) => queue.enqueue(request(`${i}`)))); + + expect(maxActive).toBe(1); + }); + + it("delivers each handler result to its own caller", async () => { + const outcomes: SessionSteeringResponse["outcome"][] = ["injected", "startedNewTurn", "injected"]; + let call = 0; + const queue = new SteeringQueue(async () => ({outcome: outcomes[call++]!})); + + const results = await Promise.all([ + queue.enqueue(request("a")), + queue.enqueue(request("b")), + queue.enqueue(request("c")), + ]); + + expect(results).toEqual([ + {outcome: "injected"}, + {outcome: "startedNewTurn"}, + {outcome: "injected"}, + ]); + }); + + it("rejects only the failing caller and keeps draining the rest", async () => { + const seen: string[] = []; + const queue = new SteeringQueue(async (params) => { + const text = (params.prompt[0] as {text: string}).text; + seen.push(text); + if (text === "boom") { + throw new Error("steer failed"); + } + return {outcome: "injected"}; + }); + + const first = queue.enqueue(request("ok")); + const failing = queue.enqueue(request("boom")); + const third = queue.enqueue(request("after")); + + await expect(first).resolves.toEqual({outcome: "injected"}); + await expect(failing).rejects.toThrow("steer failed"); + await expect(third).resolves.toEqual({outcome: "injected"}); + expect(seen).toEqual(["ok", "boom", "after"]); + }); + + it("reports isIdle before, during, and after processing", async () => { + const gate = deferred(); + const queue = new SteeringQueue(async () => { + await gate.promise; + return {outcome: "injected"}; + }); + + expect(queue.isIdle).toBe(true); + + const inFlight = queue.enqueue(request("a")); + expect(queue.isIdle).toBe(false); + + gate.resolve(); + await inFlight; + expect(queue.isIdle).toBe(true); + }); +}); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index e12e4ee3..ba2c8185 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -11,6 +11,7 @@ import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import {AgentMode} from "../AgentMode"; +import {DEFAULT_COLLABORATION_MODE} from "../CollaborationModeConfig"; import {expect, vi} from "vitest"; import type {Model, ReasoningEffortOption} from "../app-server/v2"; @@ -385,11 +386,11 @@ export function createTestSessionState(overrides?: Partial): Sessi supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, + collaborationMode: DEFAULT_COLLABORATION_MODE, fastModeEnabled: false, currentModelSupportsFast: false, - planModeEnabled: false, - planModeExplicitlySet: false, terminalOutputMode: "terminal_output_delta", + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown", ...overrides, diff --git a/src/index.ts b/src/index.ts index b886c6b7..ae493f0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,9 @@ import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; import { + GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, + SESSION_STEERING_METHOD, } from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( @@ -26,6 +28,17 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); +const sessionSteerParamsParser = z.object({ + sessionId: z.string(), + prompt: z.array(z.any()), + steerId: z.string().min(1).optional(), +}).passthrough(); + +const goalControlParamsParser = z.object({ + sessionId: z.string(), + action: z.enum(["pause", "clear"]), +}).passthrough(); + if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -135,5 +148,7 @@ function startAcpServer() { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) + .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) .connect(acpJsonStream); } diff --git a/tsconfig.json b/tsconfig.json index 39a4fd81..423a1d8f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "noUncheckedSideEffectImports": true, "skipLibCheck": true, }, - "exclude": [".claude"] + "exclude": [".claude", "examples"] }