diff --git a/.context/notes.md b/.context/notes.md new file mode 100644 index 00000000..e69de29b diff --git a/.context/todos.md b/.context/todos.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx index a224acd0..29b52eec 100644 --- a/docs/agent-sessions.mdx +++ b/docs/agent-sessions.mdx @@ -96,6 +96,14 @@ const session = await sdk.createSession({ }); ``` +```ts +// Claude permission modes are exposed as a first-class helper. +const claude = await sdk.createSession({ + agent: "claude", + permissionMode: "default", +}); +``` + ```ts // After creation await session.setModel("gpt-5.2-codex"); @@ -103,6 +111,10 @@ await session.setMode("full-access"); await session.setThoughtLevel("medium"); ``` +```ts +await claude.setPermissionMode("acceptEdits"); +``` + Query available modes: ```ts @@ -125,9 +137,30 @@ for (const opt of options) { await session.setConfigOption("some-agent-option", "value"); ``` +## Handle permission requests + +For agents that use ACP `session/request_permission` (for example Claude in `default` mode), register a permission listener and reply with `once`, `always`, or `reject`: + +```ts +const claude = await sdk.createSession({ + agent: "claude", + permissionMode: "default", +}); + +claude.onPermissionRequest((request) => { + console.log(request.toolCall.title, request.availableReplies); + void claude.replyPermission(request.id, "once"); +}); + +await claude.prompt([ + { type: "text", text: "Create ./permission-example.txt with the text hello." }, +]); +``` + +See `examples/claude-permissions/src/index.ts` for a complete Claude example with interactive approve/reject handling. + ## Destroy a session ```ts await sdk.destroySession(session.id); ``` - diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index 5bd2a508..7196d677 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -138,6 +138,19 @@ const options = await session.getConfigOptions(); const modes = await session.getModes(); ``` +Claude permission modes use the same surface: + +```ts +const claude = await sdk.createSession({ + agent: "claude", + permissionMode: "default", +}); + +claude.onPermissionRequest((request) => { + void claude.replyPermission(request.id, "once"); +}); +``` + See [Agent Sessions](/agent-sessions) for full details on config options and error handling. ## Events diff --git a/examples/claude-permissions/package.json b/examples/claude-permissions/package.json new file mode 100644 index 00000000..32b80af6 --- /dev/null +++ b/examples/claude-permissions/package.json @@ -0,0 +1,17 @@ +{ + "name": "@sandbox-agent/example-claude-permissions", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "sandbox-agent": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/examples/claude-permissions/src/index.ts b/examples/claude-permissions/src/index.ts new file mode 100644 index 00000000..7e3b6eea --- /dev/null +++ b/examples/claude-permissions/src/index.ts @@ -0,0 +1,158 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { + SandboxAgent, + type PermissionReply, + type SessionPermissionRequest, +} from "sandbox-agent"; + +const permissionMode = process.env.CLAUDE_PERMISSION_MODE?.trim() || "default"; +const autoReply = parsePermissionReply(process.env.CLAUDE_PERMISSION_REPLY); +const promptText = + process.env.CLAUDE_PERMISSION_PROMPT?.trim() || + "Create ./permission-example.txt with the text 'hello from Claude permissions example'."; + +const sdk = await SandboxAgent.start({ + spawn: { + enabled: true, + log: "inherit", + }, +}); + +try { + await sdk.installAgent("claude"); + + const agents = await sdk.listAgents({ config: true }); + const claude = agents.agents.find((agent) => agent.id === "claude"); + const configOptions = Array.isArray(claude?.configOptions) + ? (claude.configOptions as Array<{ category?: string; options?: unknown[] }>) + : []; + const modeOption = configOptions.find((option) => option.category === "mode"); + const availableModes = extractOptionValues(modeOption); + + console.log(`Claude permission mode: ${permissionMode}`); + if (availableModes.length > 0) { + console.log(`Available Claude modes: ${availableModes.join(", ")}`); + } + console.log(`Working directory: ${process.cwd()}`); + console.log(`Prompt: ${promptText}`); + if (autoReply) { + console.log(`Automatic permission reply: ${autoReply}`); + } else { + console.log("Interactive permission replies enabled."); + } + + const session = await sdk.createSession({ + agent: "claude", + permissionMode, + sessionInit: { + cwd: process.cwd(), + mcpServers: [], + }, + }); + + const rl = autoReply + ? null + : createInterface({ + input, + output, + }); + + session.onPermissionRequest((request: SessionPermissionRequest) => { + void handlePermissionRequest(session, request, autoReply, rl); + }); + + const response = await session.prompt([{ type: "text", text: promptText }]); + console.log(`Prompt finished with stopReason=${response.stopReason}`); + + await rl?.close(); +} finally { + await sdk.dispose(); +} + +async function handlePermissionRequest( + session: { + replyPermission(permissionId: string, reply: PermissionReply): Promise; + }, + request: SessionPermissionRequest, + auto: PermissionReply | null, + rl: ReturnType | null, +): Promise { + const reply = auto ?? (await promptForReply(request, rl)); + console.log(`Permission ${reply}: ${request.toolCall.title ?? request.toolCall.toolCallId}`); + await session.replyPermission(request.id, reply); +} + +async function promptForReply( + request: SessionPermissionRequest, + rl: ReturnType | null, +): Promise { + if (!rl) { + return "reject"; + } + + const title = request.toolCall.title ?? request.toolCall.toolCallId; + const available = request.availableReplies; + console.log(""); + console.log(`Permission request: ${title}`); + console.log(`Available replies: ${available.join(", ")}`); + const answer = (await rl.question("Reply [once|always|reject]: ")).trim().toLowerCase(); + const parsed = parsePermissionReply(answer); + if (parsed && available.includes(parsed)) { + return parsed; + } + + console.log("Invalid reply, defaulting to reject."); + return "reject"; +} + +function extractOptionValues(option: { options?: unknown[] } | undefined): string[] { + if (!option?.options) { + return []; + } + + const values: string[] = []; + for (const entry of option.options) { + if (!entry || typeof entry !== "object") { + continue; + } + const value = "value" in entry && typeof entry.value === "string" ? entry.value : null; + if (value) { + values.push(value); + continue; + } + if (!("options" in entry) || !Array.isArray(entry.options)) { + continue; + } + for (const nested of entry.options) { + if (!nested || typeof nested !== "object") { + continue; + } + const nestedValue = + "value" in nested && typeof nested.value === "string" ? nested.value : null; + if (nestedValue) { + values.push(nestedValue); + } + } + } + + return [...new Set(values)]; +} + +function parsePermissionReply(value: string | undefined): PermissionReply | null { + if (!value) { + return null; + } + + switch (value.trim().toLowerCase()) { + case "once": + return "once"; + case "always": + return "always"; + case "reject": + case "deny": + return "reject"; + default: + return null; + } +} diff --git a/examples/claude-permissions/tsconfig.json b/examples/claude-permissions/tsconfig.json new file mode 100644 index 00000000..9c9fe06b --- /dev/null +++ b/examples/claude-permissions/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0825225..85b65c8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(yaml@2.8.2) examples/boxlite: dependencies: @@ -47,6 +47,22 @@ importers: specifier: latest version: 5.9.3 + examples/claude-permissions: + dependencies: + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript + devDependencies: + '@types/node': + specifier: latest + version: 25.4.0 + tsx: + specifier: latest + version: 4.21.0 + typescript: + specifier: latest + version: 5.9.3 + examples/cloudflare: dependencies: '@cloudflare/sandbox': @@ -537,7 +553,7 @@ importers: version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': specifier: ^5.0.3 - version: 5.1.4(vite@7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(vite@7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) react-grab: specifier: ^0.1.13 version: 0.1.27(@types/react@18.3.27)(react@19.2.4) @@ -546,7 +562,7 @@ importers: version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) vite: specifier: ^7.1.3 - version: 7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) factory/packages/frontend-errors: dependencies: @@ -562,7 +578,7 @@ importers: version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) vite: specifier: ^7.1.3 - version: 7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) factory/packages/shared: dependencies: @@ -600,7 +616,7 @@ importers: version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@5.4.21(@types/node@25.3.5)) + version: 4.7.0(vite@5.4.21(@types/node@25.4.0)) fake-indexeddb: specifier: ^6.2.4 version: 6.2.5 @@ -612,25 +628,25 @@ importers: version: 5.9.3 vite: specifier: ^5.4.7 - version: 5.4.21(@types/node@25.3.5) + version: 5.4.21(@types/node@25.4.0) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2) frontend/packages/website: dependencies: '@astrojs/react': specifier: ^4.2.0 - version: 4.4.2(@types/node@25.3.5)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) + version: 4.4.2(@types/node@25.4.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) '@astrojs/sitemap': specifier: ^3.2.0 version: 3.7.0 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) + version: 6.0.2(astro@5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) astro: specifier: ^5.1.0 - version: 5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) framer-motion: specifier: ^12.0.0 version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -772,7 +788,7 @@ importers: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2) sdks/cli-shared: devDependencies: @@ -820,7 +836,7 @@ importers: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2) sdks/gigacode/platforms/darwin-arm64: {} @@ -3236,6 +3252,9 @@ packages: '@types/node@25.3.5': resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + '@types/node@25.4.0': + resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==} + '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} @@ -6800,15 +6819,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@25.3.5)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)': + '@astrojs/react@4.4.2(@types/node@25.4.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)': dependencies: '@types/react': 18.3.27 '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) ultrahtml: 1.6.0 - vite: 6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -6829,9 +6848,9 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.25.76 - '@astrojs/tailwind@6.0.2(astro@5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': + '@astrojs/tailwind@6.0.2(astro@5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': dependencies: - astro: 5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) autoprefixer: 10.4.23(postcss@8.5.6) postcss: 8.5.6 postcss-load-config: 4.0.2(postcss@8.5.6) @@ -9320,6 +9339,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@25.4.0': + dependencies: + undici-types: 7.18.2 + '@types/pg@8.16.0': dependencies: '@types/node': 24.10.9 @@ -9382,7 +9405,7 @@ snapshots: - bare-abort-controller - react-native-b4a - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.3.5))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.4.0))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -9390,7 +9413,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@25.3.5) + vite: 5.4.21(@types/node@25.4.0) transitivePeerDependencies: - supports-color @@ -9406,7 +9429,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -9414,7 +9449,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -9450,6 +9485,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@25.3.5) + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.4.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.4.0) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -9540,7 +9583,7 @@ snapshots: assertion-error@2.0.1: {} - astro@5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + astro@5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.5 @@ -9597,8 +9640,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.4(aws4fetch@1.0.20) vfile: 6.0.3 - vite: 6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -13059,13 +13102,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@24.10.9)(jiti@1.21.7)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.9)(jiti@1.21.7)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -13101,6 +13144,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@5.4.21(@types/node@22.19.7): dependencies: esbuild: 0.21.5 @@ -13128,6 +13192,15 @@ snapshots: '@types/node': 25.3.5 fsevents: 2.3.3 + vite@5.4.21(@types/node@25.4.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.56.0 + optionalDependencies: + '@types/node': 25.4.0 + fsevents: 2.3.3 + vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -13143,7 +13216,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@6.4.1(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@24.10.9)(jiti@1.21.7)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -13155,7 +13228,6 @@ snapshots: '@types/node': 24.10.9 fsevents: 2.3.3 jiti: 1.21.7 - tsx: 4.21.0 yaml: 2.8.2 vite@6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): @@ -13173,7 +13245,36 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.4.0 + fsevents: 2.3.3 + jiti: 1.21.7 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.4.0 + fsevents: 2.3.3 + jiti: 1.21.7 + yaml: 2.8.2 + + vite@7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -13182,15 +13283,15 @@ snapshots: rollup: 4.56.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 25.4.0 fsevents: 2.3.3 jiti: 1.21.7 tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: @@ -13234,7 +13335,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -13257,7 +13358,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 5.4.21(@types/node@24.10.9) - vite-node: 3.2.4(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.9)(jiti@1.21.7)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -13318,6 +13419,48 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.4.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21(@types/node@25.4.0) + vite-node: 3.2.4(@types/node@25.4.0)(jiti@1.21.7)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vscode-languageserver-textdocument@1.0.12: {} vscode-languageserver-types@3.17.5: {} diff --git a/research/acp/friction.md b/research/acp/friction.md index 023b4f6a..ca3b9500 100644 --- a/research/acp/friction.md +++ b/research/acp/friction.md @@ -247,3 +247,13 @@ Update this file continuously during the migration. - Owner: Unassigned. - Status: in_progress - Links: `research/acp/simplify-server.md`, `docs/mcp-config.mdx`, `docs/skills-config.mdx` + +- Date: 2026-03-10 +- Area: ACP HTTP client transport reentrancy for human-in-the-loop requests +- Issue: The TypeScript `acp-http-client` serialized the full lifetime of each POST on a single write queue. A long-running `session/prompt` request therefore blocked the client from POSTing a response to an agent-initiated `session/request_permission`, deadlocking permission approval flows. +- Impact: Permission requests arrived over SSE, but replying to them never resumed the original prompt turn. This blocked Claude and any other ACP agent using `session/request_permission`. +- Proposed direction: Make the HTTP transport fire POSTs asynchronously after preserving outbound ordering at enqueue time, rather than waiting for the entire HTTP response before the next write can begin. Keep response bodies routed back into the readable stream so request promises still resolve normally. +- Decision: Accepted and implemented in `acp-http-client`. +- Owner: Unassigned. +- Status: resolved +- Links: `sdks/acp-http-client/src/index.ts`, `sdks/acp-http-client/tests/smoke.test.ts`, `sdks/typescript/tests/integration.test.ts` diff --git a/sdks/acp-http-client/src/index.ts b/sdks/acp-http-client/src/index.ts index 5f679970..2a5dab74 100644 --- a/sdks/acp-http-client/src/index.ts +++ b/sdks/acp-http-client/src/index.ts @@ -378,31 +378,39 @@ class StreamableHttpAcpTransport { }); const url = this.buildUrl(this.bootstrapQueryIfNeeded()); - const response = await this.fetcher(url, { - method: "POST", - headers, - body: JSON.stringify(message), - }); - this.postedOnce = true; + this.ensureSseLoop(); + void this.postMessage(url, headers, message); + } - if (!response.ok) { - throw new AcpHttpError(response.status, await readProblem(response), response); - } + private async postMessage(url: string, headers: Headers, message: AnyMessage): Promise { + try { + const response = await this.fetcher(url, { + method: "POST", + headers, + body: JSON.stringify(message), + }); - this.ensureSseLoop(); + if (!response.ok) { + throw new AcpHttpError(response.status, await readProblem(response), response); + } - if (response.status === 200) { - const text = await response.text(); - if (text.trim()) { - const envelope = JSON.parse(text) as AnyMessage; - this.pushInbound(envelope); + if (response.status === 200) { + const text = await response.text(); + if (text.trim()) { + const envelope = JSON.parse(text) as AnyMessage; + this.pushInbound(envelope); + } + return; } - } else { + // Drain response body so the underlying connection is released back to - // the pool. Without this, Node.js undici keeps the socket occupied and + // the pool. Without this, Node.js undici keeps the socket occupied and // may stall subsequent requests to the same origin. await response.text().catch(() => {}); + } catch (error) { + console.error("ACP write error:", error); + this.failReadable(error); } } diff --git a/sdks/acp-http-client/tests/smoke.test.ts b/sdks/acp-http-client/tests/smoke.test.ts index 8b92e6cd..f229f7ec 100644 --- a/sdks/acp-http-client/tests/smoke.test.ts +++ b/sdks/acp-http-client/tests/smoke.test.ts @@ -140,4 +140,54 @@ describe("AcpHttpClient integration", () => { await client.disconnect(); }); + + it("answers session/request_permission while session/prompt is still in flight", async () => { + const permissionRequests: Array<{ sessionId: string; title?: string | null }> = []; + const serverId = `acp-http-client-permissions-${Date.now().toString(36)}`; + + const client = new AcpHttpClient({ + baseUrl, + token, + transport: { + path: `/v1/acp/${encodeURIComponent(serverId)}`, + bootstrapQuery: { agent: "mock" }, + }, + client: { + requestPermission: async (request) => { + permissionRequests.push({ + sessionId: request.sessionId, + title: request.toolCall.title, + }); + return { + outcome: { + outcome: "selected", + optionId: "reject-once", + }, + }; + }, + }, + }); + + await client.initialize(); + + const session = await client.newSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + const prompt = await client.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "please trigger permission" }], + }); + + expect(prompt.stopReason).toBe("end_turn"); + expect(permissionRequests).toEqual([ + { + sessionId: session.sessionId, + title: "Write mock.txt", + }, + ]); + + await client.disconnect(); + }); }); diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 9db97628..4fff3e32 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -8,8 +8,12 @@ import { type CancelNotification, type NewSessionRequest, type NewSessionResponse, + type PermissionOption, + type PermissionOptionKind, type PromptRequest, type PromptResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, type SessionConfigOption, type SessionNotification, type SessionModeState, @@ -125,6 +129,7 @@ export interface SessionCreateRequest { sessionInit?: Omit; model?: string; mode?: string; + permissionMode?: string; thoughtLevel?: string; } @@ -134,6 +139,7 @@ export interface SessionResumeOrCreateRequest { sessionInit?: Omit; model?: string; mode?: string; + permissionMode?: string; thoughtLevel?: string; } @@ -142,9 +148,28 @@ export interface SessionSendOptions { } export type SessionEventListener = (event: SessionEvent) => void; +export type PermissionReply = "once" | "always" | "reject"; +export type PermissionRequestListener = (request: SessionPermissionRequest) => void; export type ProcessLogListener = (entry: ProcessLogEntry) => void; export type ProcessLogFollowQuery = Omit; +export interface SessionPermissionRequestOption { + optionId: string; + name: string; + kind: PermissionOptionKind; +} + +export interface SessionPermissionRequest { + id: string; + createdAt: number; + sessionId: string; + agentSessionId: string; + availableReplies: PermissionReply[]; + options: SessionPermissionRequestOption[]; + toolCall: RequestPermissionRequest["toolCall"]; + rawRequest: RequestPermissionRequest; +} + export interface AgentQueryOptions { config?: boolean; noCache?: boolean; @@ -238,6 +263,22 @@ export class UnsupportedSessionConfigOptionError extends Error { } } +export class UnsupportedPermissionReplyError extends Error { + readonly permissionId: string; + readonly requestedReply: PermissionReply; + readonly availableReplies: PermissionReply[]; + + constructor(permissionId: string, requestedReply: PermissionReply, availableReplies: PermissionReply[]) { + super( + `Permission '${permissionId}' does not support reply '${requestedReply}'. Available replies: ${availableReplies.join(", ") || "(none)"}`, + ); + this.name = "UnsupportedPermissionReplyError"; + this.permissionId = permissionId; + this.requestedReply = requestedReply; + this.availableReplies = availableReplies; + } +} + export class Session { private record: SessionRecord; private readonly sandbox: SandboxAgent; @@ -297,6 +338,14 @@ export class Session { return updated.response; } + async setPermissionMode( + permissionMode: string, + ): Promise { + const updated = await this.sandbox.setSessionPermissionMode(this.id, permissionMode); + this.apply(updated.session.toRecord()); + return updated.response; + } + async setConfigOption(configId: string, value: string): Promise { const updated = await this.sandbox.setSessionConfigOption(this.id, configId, value); this.apply(updated.session.toRecord()); @@ -327,6 +376,18 @@ export class Session { return this.sandbox.onSessionEvent(this.id, listener); } + onPermissionRequest(listener: PermissionRequestListener): () => void { + return this.sandbox.onPermissionRequest(this.id, listener); + } + + async replyPermission(permissionId: string, reply: PermissionReply): Promise { + await this.sandbox.replyPermission(permissionId, reply); + } + + async respondToPermission(permissionId: string, response: RequestPermissionResponse): Promise { + await this.sandbox.respondToPermission(permissionId, response); + } + toRecord(): SessionRecord { return { ...this.record }; } @@ -355,6 +416,12 @@ export class LiveAcpConnection { direction: AcpEnvelopeDirection, localSessionId: string | null, ) => void; + private readonly onPermissionRequest: ( + connection: LiveAcpConnection, + localSessionId: string, + agentSessionId: string, + request: RequestPermissionRequest, + ) => Promise; private constructor( agent: string, @@ -366,11 +433,18 @@ export class LiveAcpConnection { direction: AcpEnvelopeDirection, localSessionId: string | null, ) => void, + onPermissionRequest: ( + connection: LiveAcpConnection, + localSessionId: string, + agentSessionId: string, + request: RequestPermissionRequest, + ) => Promise, ) { this.agent = agent; this.connectionId = connectionId; this.acp = acp; this.onObservedEnvelope = onObservedEnvelope; + this.onPermissionRequest = onPermissionRequest; } static async create(options: { @@ -386,6 +460,12 @@ export class LiveAcpConnection { direction: AcpEnvelopeDirection, localSessionId: string | null, ) => void; + onPermissionRequest: ( + connection: LiveAcpConnection, + localSessionId: string, + agentSessionId: string, + request: RequestPermissionRequest, + ) => Promise; }): Promise { const connectionId = randomId(); @@ -400,6 +480,12 @@ export class LiveAcpConnection { bootstrapQuery: { agent: options.agent }, }, client: { + requestPermission: async (request: RequestPermissionRequest) => { + if (!live) { + return cancelledPermissionResponse(); + } + return live.handlePermissionRequest(request); + }, sessionUpdate: async (_notification: SessionNotification) => { // Session updates are observed via envelope persistence. }, @@ -416,7 +502,13 @@ export class LiveAcpConnection { }, }); - live = new LiveAcpConnection(options.agent, connectionId, acp, options.onObservedEnvelope); + live = new LiveAcpConnection( + options.agent, + connectionId, + acp, + options.onObservedEnvelope, + options.onPermissionRequest, + ); const initResult = await acp.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -550,6 +642,23 @@ export class LiveAcpConnection { this.lastAdapterExitAt = Date.now(); } + private async handlePermissionRequest( + request: RequestPermissionRequest, + ): Promise { + const agentSessionId = request.sessionId; + const localSessionId = this.localByAgentSessionId.get(agentSessionId); + if (!localSessionId) { + return cancelledPermissionResponse(); + } + + return this.onPermissionRequest( + this, + localSessionId, + agentSessionId, + clonePermissionRequest(request), + ); + } + private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null { const id = envelopeId(envelope); const method = envelopeMethod(envelope); @@ -782,6 +891,8 @@ export class SandboxAgent { private readonly pendingLiveConnections = new Map>(); private readonly sessionHandles = new Map(); private readonly eventListeners = new Map>(); + private readonly permissionListeners = new Map>(); + private readonly pendingPermissionRequests = new Map(); private readonly nextSessionEventIndexBySession = new Map(); private readonly seedSessionEventIndexBySession = new Map>(); @@ -840,6 +951,11 @@ export class SandboxAgent { this.disposed = true; this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed.")); + for (const [permissionId, pending] of this.pendingPermissionRequests) { + this.pendingPermissionRequests.delete(permissionId); + pending.resolve(cancelledPermissionResponse()); + } + const connections = [...this.liveConnections.values()]; this.liveConnections.clear(); const pending = [...this.pendingLiveConnections.values()]; @@ -910,11 +1026,15 @@ export class SandboxAgent { this.nextSessionEventIndexBySession.set(record.id, 1); live.bindSession(record.id, record.agentSessionId); let session = this.upsertSessionHandle(record); + assertNoConflictingPermissionMode(request.mode, request.permissionMode); try { if (request.mode) { session = (await this.setSessionMode(session.id, request.mode)).session; } + if (request.permissionMode) { + session = (await this.setSessionPermissionMode(session.id, request.permissionMode)).session; + } if (request.model) { session = (await this.setSessionModel(session.id, request.model)).session; } @@ -969,9 +1089,13 @@ export class SandboxAgent { const existing = await this.persist.getSession(request.id); if (existing) { let session = await this.resumeSession(existing.id); + assertNoConflictingPermissionMode(request.mode, request.permissionMode); if (request.mode) { session = (await this.setSessionMode(session.id, request.mode)).session; } + if (request.permissionMode) { + session = (await this.setSessionPermissionMode(session.id, request.permissionMode)).session; + } if (request.model) { session = (await this.setSessionModel(session.id, request.model)).session; } @@ -984,6 +1108,8 @@ export class SandboxAgent { } async destroySession(id: string): Promise { + this.cancelPendingPermissionsForSession(id); + try { await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true); } catch { @@ -1085,6 +1211,24 @@ export class SandboxAgent { return this.setSessionCategoryValue(sessionId, "model", model); } + async setSessionPermissionMode( + sessionId: string, + permissionMode: string, + ): Promise<{ session: Session; response: SetSessionModeResponse | SetSessionConfigOptionResponse | void }> { + const resolvedValue = permissionMode.trim(); + if (!resolvedValue) { + throw new Error("setSessionPermissionMode requires a non-empty permissionMode"); + } + + const options = await this.getSessionConfigOptions(sessionId); + const permissionOption = findConfigOptionByCategory(options, "permission_mode"); + if (permissionOption) { + return this.setSessionConfigOption(sessionId, permissionOption.id, resolvedValue); + } + + return this.setSessionMode(sessionId, resolvedValue); + } + async setSessionThoughtLevel( sessionId: string, thoughtLevel: string, @@ -1100,7 +1244,26 @@ export class SandboxAgent { async getSessionModes(sessionId: string): Promise { const record = await this.requireSessionRecord(sessionId); - return cloneModes(record.modes); + if (record.modes && record.modes.availableModes.length > 0) { + return cloneModes(record.modes); + } + + const hydrated = await this.hydrateSessionConfigOptions(record.id, record); + if (hydrated.modes && hydrated.modes.availableModes.length > 0) { + return cloneModes(hydrated.modes); + } + + const derived = deriveModesFromConfigOptions(hydrated.configOptions); + if (!derived) { + return cloneModes(hydrated.modes); + } + + const updated: SessionRecord = { + ...hydrated, + modes: derived, + }; + await this.persist.updateSession(updated); + return cloneModes(derived); } private async setSessionCategoryValue( @@ -1135,7 +1298,7 @@ export class SandboxAgent { } private async hydrateSessionConfigOptions(sessionId: string, snapshot: SessionRecord): Promise { - if (snapshot.configOptions !== undefined) { + if (snapshot.configOptions !== undefined && snapshot.configOptions.length > 0) { return snapshot; } @@ -1290,6 +1453,40 @@ export class SandboxAgent { }; } + onPermissionRequest(sessionId: string, listener: PermissionRequestListener): () => void { + const listeners = this.permissionListeners.get(sessionId) ?? new Set(); + listeners.add(listener); + this.permissionListeners.set(sessionId, listeners); + + return () => { + const set = this.permissionListeners.get(sessionId); + if (!set) { + return; + } + set.delete(listener); + if (set.size === 0) { + this.permissionListeners.delete(sessionId); + } + }; + } + + async replyPermission(permissionId: string, reply: PermissionReply): Promise { + const pending = this.pendingPermissionRequests.get(permissionId); + if (!pending) { + throw new Error(`permission '${permissionId}' not found`); + } + + const response = permissionReplyToResponse(permissionId, pending.request, reply); + this.resolvePendingPermission(permissionId, response); + } + + async respondToPermission(permissionId: string, response: RequestPermissionResponse): Promise { + if (!this.pendingPermissionRequests.has(permissionId)) { + throw new Error(`permission '${permissionId}' not found`); + } + this.resolvePendingPermission(permissionId, clonePermissionResponse(response)); + } + async getHealth(): Promise { return this.requestHealth(); } @@ -1301,9 +1498,22 @@ export class SandboxAgent { } async getAgent(agent: string, options?: AgentQueryOptions): Promise { - return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, { - query: toAgentQuery(options), - }); + try { + return await this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, { + query: toAgentQuery(options), + }); + } catch (error) { + if (!(error instanceof SandboxAgentError) || error.status !== 404) { + throw error; + } + + const listed = await this.listAgents(options); + const match = listed.agents.find((entry) => entry.id === agent); + if (match) { + return match; + } + throw error; + } } async installAgent(agent: string, request: AgentInstallRequest = {}): Promise { @@ -1551,6 +1761,8 @@ export class SandboxAgent { onObservedEnvelope: (connection, envelope, direction, localSessionId) => { void this.persistObservedEnvelope(connection, envelope, direction, localSessionId); }, + onPermissionRequest: async (connection, localSessionId, agentSessionId, request) => + this.enqueuePermissionRequest(connection, localSessionId, agentSessionId, request), }); const raced = this.liveConnections.get(agent); @@ -1753,6 +1965,69 @@ export class SandboxAgent { return record; } + private async enqueuePermissionRequest( + _connection: LiveAcpConnection, + localSessionId: string, + agentSessionId: string, + request: RequestPermissionRequest, + ): Promise { + const listeners = this.permissionListeners.get(localSessionId); + if (!listeners || listeners.size === 0) { + return cancelledPermissionResponse(); + } + + const pendingId = randomId(); + const permissionRequest: SessionPermissionRequest = { + id: pendingId, + createdAt: nowMs(), + sessionId: localSessionId, + agentSessionId, + availableReplies: availablePermissionReplies(request.options), + options: request.options.map(clonePermissionOption), + toolCall: clonePermissionToolCall(request.toolCall), + rawRequest: clonePermissionRequest(request), + }; + + return await new Promise((resolve, reject) => { + this.pendingPermissionRequests.set(pendingId, { + id: pendingId, + sessionId: localSessionId, + request: clonePermissionRequest(request), + resolve, + reject, + }); + + try { + for (const listener of listeners) { + listener(permissionRequest); + } + } catch (error) { + this.pendingPermissionRequests.delete(pendingId); + reject(error); + } + }); + } + + private resolvePendingPermission(permissionId: string, response: RequestPermissionResponse): void { + const pending = this.pendingPermissionRequests.get(permissionId); + if (!pending) { + throw new Error(`permission '${permissionId}' not found`); + } + + this.pendingPermissionRequests.delete(permissionId); + pending.resolve(response); + } + + private cancelPendingPermissionsForSession(sessionId: string): void { + for (const [permissionId, pending] of this.pendingPermissionRequests) { + if (pending.sessionId !== sessionId) { + continue; + } + this.pendingPermissionRequests.delete(permissionId); + pending.resolve(cancelledPermissionResponse()); + } + } + private async requestJson(method: string, path: string, options: RequestOptions = {}): Promise { const response = await this.requestRaw(method, path, { query: options.query, @@ -1922,6 +2197,14 @@ export class SandboxAgent { } } +type PendingPermissionRequestState = { + id: string; + sessionId: string; + request: RequestPermissionRequest; + resolve: (response: RequestPermissionResponse) => void; + reject: (reason?: unknown) => void; +}; + type QueryValue = string | number | boolean | null | undefined; type RequestOptions = { @@ -2166,6 +2449,26 @@ function cloneEnvelope(envelope: AnyMessage): AnyMessage { return JSON.parse(JSON.stringify(envelope)) as AnyMessage; } +function clonePermissionRequest(request: RequestPermissionRequest): RequestPermissionRequest { + return JSON.parse(JSON.stringify(request)) as RequestPermissionRequest; +} + +function clonePermissionResponse(response: RequestPermissionResponse): RequestPermissionResponse { + return JSON.parse(JSON.stringify(response)) as RequestPermissionResponse; +} + +function clonePermissionOption(option: PermissionOption): SessionPermissionRequestOption { + return { + optionId: option.optionId, + name: option.name, + kind: option.kind, + }; +} + +function clonePermissionToolCall(toolCall: RequestPermissionRequest["toolCall"]): RequestPermissionRequest["toolCall"] { + return JSON.parse(JSON.stringify(toolCall)) as RequestPermissionRequest["toolCall"]; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -2314,6 +2617,35 @@ function extractKnownModeIds(modes: SessionModeState | null | undefined): string .filter((value): value is string => !!value); } +function deriveModesFromConfigOptions( + configOptions: SessionConfigOption[] | undefined, +): SessionModeState | null { + if (!configOptions || configOptions.length === 0) { + return null; + } + + const modeOption = findConfigOptionByCategory(configOptions, "mode"); + if (!modeOption || !Array.isArray(modeOption.options)) { + return null; + } + + const availableModes = modeOption.options + .flatMap((entry) => flattenConfigOptions(entry)) + .map((entry) => ({ + id: entry.value, + name: entry.name, + description: entry.description ?? null, + })); + + return { + currentModeId: + typeof modeOption.currentValue === "string" && modeOption.currentValue.length > 0 + ? modeOption.currentValue + : availableModes[0]?.id ?? "", + availableModes, + }; +} + function applyCurrentMode( modes: SessionModeState | null | undefined, currentModeId: string, @@ -2344,6 +2676,25 @@ function applyConfigOptionValue( return updated; } +function flattenConfigOptions(entry: unknown): Array<{ value: string; name: string; description?: string }> { + if (!isRecord(entry)) { + return []; + } + if (typeof entry.value === "string" && typeof entry.name === "string") { + return [ + { + value: entry.value, + name: entry.name, + description: typeof entry.description === "string" ? entry.description : undefined, + }, + ]; + } + if (!Array.isArray(entry.options)) { + return []; + } + return entry.options.flatMap((nested) => flattenConfigOptions(nested)); +} + function envelopeSessionUpdate(message: AnyMessage): Record | null { if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) { return null; @@ -2368,6 +2719,70 @@ function cloneModes(value: SessionModeState | null | undefined): SessionModeStat return JSON.parse(JSON.stringify(value)) as SessionModeState; } +function assertNoConflictingPermissionMode(mode: string | undefined, permissionMode: string | undefined): void { + if (!mode || !permissionMode) { + return; + } + if (mode.trim() === permissionMode.trim()) { + return; + } + throw new Error("createSession/resumeOrCreate received conflicting values for mode and permissionMode"); +} + +function availablePermissionReplies(options: PermissionOption[]): PermissionReply[] { + const replies = new Set(); + for (const option of options) { + if (option.kind === "allow_once") { + replies.add("once"); + } else if (option.kind === "allow_always") { + replies.add("always"); + } else if (option.kind === "reject_once" || option.kind === "reject_always") { + replies.add("reject"); + } + } + return [...replies]; +} + +function permissionReplyToResponse( + permissionId: string, + request: RequestPermissionRequest, + reply: PermissionReply, +): RequestPermissionResponse { + const preferredKinds: PermissionOptionKind[] = + reply === "once" + ? ["allow_once", "allow_always"] + : reply === "always" + ? ["allow_always", "allow_once"] + : ["reject_once", "reject_always"]; + + const selected = preferredKinds + .map((kind) => request.options.find((option) => option.kind === kind)) + .find((option): option is PermissionOption => Boolean(option)); + + if (!selected) { + throw new UnsupportedPermissionReplyError( + permissionId, + reply, + availablePermissionReplies(request.options), + ); + } + + return { + outcome: { + outcome: "selected", + optionId: selected.optionId, + }, + }; +} + +function cancelledPermissionResponse(): RequestPermissionResponse { + return { + outcome: { + outcome: "cancelled", + }, + }; +} + function isSessionConfigOption(value: unknown): value is SessionConfigOption { return ( isRecord(value) && diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 6b5c9a45..c4144520 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -4,6 +4,7 @@ export { SandboxAgent, SandboxAgentError, Session, + UnsupportedPermissionReplyError, UnsupportedSessionCategoryError, UnsupportedSessionConfigOptionError, UnsupportedSessionValueError, @@ -28,6 +29,10 @@ export type { SessionResumeOrCreateRequest, SessionSendOptions, SessionEventListener, + PermissionReply, + PermissionRequestListener, + SessionPermissionRequest, + SessionPermissionRequestOption, } from "./client.ts"; export type { InspectorUrlOptions } from "./inspector.ts"; diff --git a/sdks/typescript/tests/helpers/mock-agent.ts b/sdks/typescript/tests/helpers/mock-agent.ts index 4c6f0648..acb378b7 100644 --- a/sdks/typescript/tests/helpers/mock-agent.ts +++ b/sdks/typescript/tests/helpers/mock-agent.ts @@ -25,10 +25,12 @@ export function prepareMockAgentDataHome(dataHome: string): Record { const hasId = Object.prototype.hasOwnProperty.call(msg, "id"); const method = hasMethod ? msg.method : undefined; + if (!hasMethod && hasId) { + const pending = pendingPermissions.get(String(msg.id)); + if (pending) { + pendingPermissions.delete(String(msg.id)); + const outcome = msg?.result?.outcome; + const optionId = outcome?.outcome === "selected" ? outcome.optionId : "cancelled"; + const suffix = optionId === "reject-once" ? "rejected" : "approved"; + emit({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: pending.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "mock permission " + suffix + ": " + optionId, + }, + }, + }, + }); + emit({ + jsonrpc: "2.0", + id: pending.promptId, + result: { + stopReason: "end_turn", + }, + }); + } + return; + } + if (method === "session/prompt") { const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : ""; const text = firstText(msg?.params?.prompt); @@ -82,6 +116,51 @@ rl.on("line", (line) => { }, }, }); + + if (text.includes("permission")) { + nextPermission += 1; + const permissionId = "permission-" + nextPermission; + pendingPermissions.set(permissionId, { + promptId: msg.id, + sessionId, + }); + emit({ + jsonrpc: "2.0", + id: permissionId, + method: "session/request_permission", + params: { + sessionId, + toolCall: { + toolCallId: "tool-call-" + nextPermission, + title: "Write mock.txt", + kind: "edit", + status: "pending", + locations: [{ path: "/tmp/mock.txt" }], + rawInput: { + path: "/tmp/mock.txt", + content: "hello", + }, + }, + options: [ + { + kind: "allow_once", + name: "Allow once", + optionId: "allow-once", + }, + { + kind: "allow_always", + name: "Always allow", + optionId: "allow-always", + }, + { + kind: "reject_once", + name: "Reject", + optionId: "reject-once", + }, + ], + }, + }); + } } if (!hasMethod || !hasId) { @@ -117,6 +196,10 @@ rl.on("line", (line) => { } if (method === "session/prompt") { + const text = firstText(msg?.params?.prompt); + if (text.includes("permission")) { + return; + } emit({ jsonrpc: "2.0", id: msg.id, diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 1da387b6..4cc0501a 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -578,6 +578,42 @@ describe("Integration: TypeScript SDK flat session API", () => { await sdk.dispose(); }); + it("supports permissionMode as a first-class session helper", async () => { + const sdk = await SandboxAgent.connect({ + baseUrl, + token, + }); + + const session = await sdk.createSession({ + agent: "mock", + permissionMode: "plan", + }); + + expect((await session.getModes())?.currentModeId).toBe("plan"); + + await session.setPermissionMode("normal"); + expect((await session.getModes())?.currentModeId).toBe("normal"); + + await sdk.dispose(); + }); + + it("rejects conflicting mode and permissionMode values", async () => { + const sdk = await SandboxAgent.connect({ + baseUrl, + token, + }); + + await expect( + sdk.createSession({ + agent: "mock", + mode: "normal", + permissionMode: "plan", + }), + ).rejects.toThrow("conflicting values"); + + await sdk.dispose(); + }); + it("setThoughtLevel happy path switches to a valid thought level", async () => { const sdk = await SandboxAgent.connect({ baseUrl, @@ -625,6 +661,43 @@ describe("Integration: TypeScript SDK flat session API", () => { await sdk.dispose(); }); + it("surfaces ACP permission requests and maps approve/reject replies", async () => { + const sdk = await SandboxAgent.connect({ + baseUrl, + token, + }); + + const session = await sdk.createSession({ agent: "mock" }); + const permissionIds: string[] = []; + const permissionTexts: string[] = []; + + const offPermissions = session.onPermissionRequest((request) => { + permissionIds.push(request.id); + const reply = permissionIds.length === 1 ? "reject" : "always"; + void session.replyPermission(request.id, reply); + }); + + const offEvents = session.onEvent((event) => { + const text = (event.payload as any)?.params?.update?.content?.text; + if (typeof text === "string" && text.startsWith("mock permission ")) { + permissionTexts.push(text); + } + }); + + await session.prompt([{ type: "text", text: "trigger permission request one" }]); + await session.prompt([{ type: "text", text: "trigger permission request two" }]); + + await waitFor(() => (permissionIds.length === 2 ? permissionIds : undefined)); + await waitFor(() => (permissionTexts.length === 2 ? permissionTexts : undefined)); + + expect(permissionTexts[0]).toContain("rejected"); + expect(permissionTexts[1]).toContain("approved"); + + offEvents(); + offPermissions(); + await sdk.dispose(); + }); + it("supports MCP and skills config HTTP helpers", async () => { const sdk = await SandboxAgent.connect({ baseUrl,