diff --git a/packages/agent/package.json b/packages/agent/package.json index 1ccc632d6e..a33d1313ef 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -128,9 +128,9 @@ "vitest": "^4.1.8" }, "dependencies": { - "@agentclientprotocol/sdk": "0.25.0", - "@anthropic-ai/claude-agent-sdk": "0.3.170", - "@anthropic-ai/sdk": "0.104.1", + "@agentclientprotocol/sdk": "1.1.0", + "@anthropic-ai/claude-agent-sdk": "0.3.197", + "@anthropic-ai/sdk": "0.109.0", "@hono/node-server": "^1.19.9", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", diff --git a/packages/agent/src/adapters/claude/SKILL.md b/packages/agent/src/adapters/claude/SKILL.md index ce8ab70e5b..c14269f989 100644 --- a/packages/agent/src/adapters/claude/SKILL.md +++ b/packages/agent/src/adapters/claude/SKILL.md @@ -152,6 +152,17 @@ pnpm --filter code test - **Single session.** The agent owns one `this.session` (from `BaseAcpAgent`), not a `sessions` map. Upstream's per-session refactors usually collapse to "just use `this.session`". +- **Prompt loop is a persistent consumer** (since the v0.54.1 sync, upstream #780): `prompt()` + enqueues a `Turn` deferred; `runConsumer` drains the query stream for the session's life, settles + turns at their terminal `result`, and captures `query` + `session.queryGeneration` so the + fork-only `refreshSession()` can retire it (bump generation → abort wake-up → end input). Steer + mode, `interruptReason`, per-turn broadcast-at-activation and the unsupported-slash-command gate + all live inside it — port upstream prompt-loop changes into the consumer, not a per-prompt loop. +- **ACP connection classes are the deprecated ones on purpose.** The fork stays on + `AgentSideConnection`/`ClientSideConnection` (still shipped in ACP 1.x) because they carry the + `extMethod`/`extNotification` surface `_posthog/*` uses; permission requests reach the client via + the class's generic `request(..., { cancellationSignal })`. Don't port the `agent()` builder + without a plan for the extension surface. - **Renderer uses config options only.** Model/mode/effort selection is `SessionConfigOption` end to end; the renderer never reads the legacy `models` response field or calls `unstable_setSessionModel`. That's why upstream's ACP-0.24/0.25 model-state removals are safe to follow. diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index 4e2c9e76de..0c11ab3d4e 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -5,8 +5,8 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Fork Point - **Forked**: v0.10.9, commit `5411e0f4`, Dec 2 2025 -- **Last sync**: v0.44.0, commit `7de5e4b`, Jun 11 2026 -- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.170, `@agentclientprotocol/sdk` 0.25.0, `@anthropic-ai/sdk` 0.104.1 +- **Last sync**: v0.54.1, commit `8d5febf`, Jul 1 2026 +- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.197, `@agentclientprotocol/sdk` 1.1.0, `@anthropic-ai/sdk` 0.109.0 ## File Mapping @@ -54,8 +54,118 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth | Auth methods | `claude-ai-login` + `console-login` | Returns empty `authMethods` | Auth handled externally | | Session fingerprinting | Implicit teardown on cwd/mcp change | Explicit `refreshSession()` | Caller-initiated is more predictable | | Shutdown on ACP close | Process exits | No standalone process | Agent is embedded in server | -| Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. | -| Prompt-loop cancel race | `Promise.race([query.next(), cancelWake])` each iteration (#742) | `withAbort(query.next(), cancelController.signal)` helper in `utils/common.ts`, also guarding the `compact_boundary` `getContextUsage` fetch | The classic `Promise.race` leak (nodejs/node#17469): each race call parks a reaction on the turn-lived `cancelWake` promise that retains that iteration's settled value, so every yielded message (and every stream event, since `includePartialMessages` is on) stays reachable until the turn ends. Long high-reasoning turns could pin tens of MB. `withAbort` removes its abort listener as soon as `next()` settles, so nothing accumulates. Cancel semantics are unchanged, including the force-cancel backstop. | +| Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged. Lives in the consumer's idle handler: fires only when idle arrives with no active turn, an unsettled head turn whose leading command is unknown, and no pending orphan results. | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. | +| Prompt-loop cancel race | Per-iteration `addEventListener`/`removeEventListener` race in the consumer (#780) | `withAbort(query.next(), cancelController.signal)` helper in `utils/common.ts`, also guarding the `compact_boundary` `getContextUsage` fetch | Same effect (no listener/reaction accumulation on the long-lived wake-up promise), different helper. `withAbort` removes its abort listener as soon as `next()` settles; the consumer re-arms a fresh controller after each abort fire, matching upstream's re-arm. | +| ACP connection wiring | `agent({name}).onRequest(...).connect(stream)` builder + narrow `AcpClient` interface (#790) | Keeps `AgentSideConnection` / `ClientSideConnection` (deprecated but fully functional in ACP 1.1.0) in `acp-connection.ts` / `base-acp-agent.ts` / codex | The fork is embedded (in-process streams, `extMethod`/`extNotification` extension surface) and the deprecated classes still route optional `extMethod`/`extNotification` to the Agent/Client. Revisit when ACP removes them; permission cancellation already uses the class's generic `request(..., { cancellationSignal })`. | +| Consumer ownership | Per-session map; consumer keyed by `sessions[id]` | Single `this.session`; consumer captures `query` + `queryGeneration` and exits quietly on mismatch | `refreshSession()` (fork-only) swaps `query`/`input` in place on the same session object; the generation guard keeps a retired consumer from tearing down the refreshed session. | + +## Changes Ported in v0.54.1 Sync + +- **SDK bumps**: claude-agent-sdk 0.3.170 -> 0.3.197, ACP SDK 0.25.0 -> 1.1.0, anthropic SDK + 0.104.1 -> 0.109.0. The ACP 1.x major is source-compatible for the fork: the deprecated + `AgentSideConnection`/`ClientSideConnection` classes are still shipped and still route + `extMethod`/`extNotification` (see Intentional Divergences). Only in-repo break was the SDK + `Query` interface gaining `setMcpPermissionModeOverride` and `reinitialize` (test mock updated). +- **Persistent consumer + turn queue** (#780, 4f273a2): The per-prompt message loop became a + single long-lived consumer per session. `prompt()` now enqueues a `Turn` (deferred) and returns; + the consumer drains the query stream for the session's whole life, activates turns via their + user-message echoes (promoting the queue head for echo-less local-only/compaction results, with + orphan-result accounting after cancels), settles turns at their terminal `result` instead of + waiting for the SDK's trailing `idle` (which can lag behind background tasks — upstream issues + #773/#679/#688), forwards between-turn/background output live, and rejects turns with a clear + "session has ended" error once the stream dies (`queryClosed`). Upstream's fixes folded in: + fresh-abort-listener per iteration (kept as `withAbort` + re-armed controller), error results + via `failActive` without killing the consumer (replaces the drain-after-error loop, #706's + successor), process-death teardown via `failAllTurns` + `closeQueryStream`. Fork adaptations: + single-session, steer mode untouched (mid-turn push + benign end_turn), `interruptReason` + carried on every cancelled settle, per-turn broadcast fired at activation (preserves the old + "broadcast when the turn takes over" timing), the unsupported-slash-command gate re-anchored on + "idle with an unactivated head turn", `toolUseStreamCache` cleared on cancel/error settles, and + a `queryGeneration` guard so `refreshSession()` retires the old consumer cleanly. +- **Content-based streamed-block dedupe** (#785 12d34e6, #789 1c80bf8, #800 960f62d — ported as + the final #800 state): `StreamedAssistantBlocks` switched from per-message-id + `textIds`/`thinkingIds` sets to an ordered accumulated-text record; the consolidated assistant + message prefix-diffs each block against what streamed and forwards only the un-streamed + remainder (nothing / whole block / cut-short tail). Robust to gateways whose consolidated + message id doesn't match the stream. Record cleared at each top-level `message_start` and after + consumption; consumer-lived so mid-message turn activation can't drop it. New unit tests cover + tail-forwarding, id-mismatch dedupe, residue clearing and empty-delta stalls. +- **Skip empty thinking chunks** (#793, 15fdf26): `handleThinkingChunk` drops signature-only + (empty) thinking blocks that models with `thinking.display: "omitted"` stream; empty deltas are + also excluded from the streamed-block record so they can't stall the diff cursor. +- **Emit tool_call before permission request** (#820, c95fc88): New agent-lived + `emittedToolCalls` set shared between the streamed tool_use path and the permission flow. + `requestPermissionFromClient` eagerly emits the referenced `tool_call` (Task*/TodoWrite + excluded; Bash carries `terminal_info`) so the client has it before being asked to approve; + whichever side runs second emits a `tool_call_update` instead of a duplicate. Pruned at + `tool_result` alongside `toolUseCache`. +- **Permission request cancellation** (#801, 9013d1d): All five permission-request sites now go + through `client.request(methods.client.session.requestPermission, params, { cancellationSignal: + signal })`, so cancelling a turn sends `$/cancel_request` and the client can dismiss the open + dialog; an abort-time rejection maps to the existing "Tool use aborted". +- **Terminal error rendering** (#776, db6eaaf): Bash `is_error` results keep flowing through the + terminal-output `_meta` channel (when the client supports it) instead of short-circuiting to + plain error content. +- **Bash image output** (#617, a759e64): Array tool_result content that isn't text-only (e.g. an + image from a piped data URI) bypasses the terminal channel and surfaces as ACP content blocks + instead of being silently dropped. +- **`informational` system subtype** (rode in with SDK 0.3.178, #777 58549ff): Surfaced as an + `agent_message_chunk` (level folded into the text for non-info levels) so hook-blocked stops are + no longer silent. `worker_shutting_down` no-ops via the existing `default: break`. +- **Sonnet 5 model-version matching** (#826, ef42c46): `MODEL_FAMILY_VERSION_PATTERN` accepts + single-number generations (`5`) and `extractModelFamilyVersion` strips `[1m]`-style context + hints before matching, so `sonnet 5` resolves and `claude-sonnet-4-6` can't cross-match a + Sonnet 5 alias. Unit tests added. +- **Session title push at turn end** (#812, 1fe7ec0): `maybeUpdateSessionTitle` polls + `getSessionInfo` at each `idle` and pushes a `session_info_update` (ACP 1.1) when the + SDK-generated `customTitle`/`summary` changes. +- **Fast mode session config** (#828, fa949a2, adapted to gateway models): New `fast` on/off + select config option, surfaced only for models in `MODELS_WITH_FAST_MODE` + (claude-opus-4-8/-4-7). Toggling calls `query.applyFlagSettings({ fastMode })`; the intent is + retained across model switches (`session.fastModeEnabled`), seeded from + `initializationResult.fast_mode_state`, and reconciled with SDK-reported `fast_mode_state` on + init and user-turn results (`cooldown` never flaps the toggle). Boolean-typed config options + were not adopted — the renderer consumes selects; revisit if it advertises + `sessionConfigOptions.boolean`. +- **ReportFindings tool rendering** (#826, ef42c46): Not ported to `toolInfoFromToolUse` — see + Skipped (the fork renders unknown tools generically and PostHog Code has no code-review + ReportFindings flow); re-evaluate if the SDK starts emitting it in our sessions. +- **Test mock**: added `setMcpPermissionModeOverride` and `reinitialize` to the SDK `MockQuery` + (new methods on the SDK `Query` interface by 0.3.197). + +## Skipped in v0.54.1 Sync + +- **ACP builder-pattern migration** (#790, 2554c7b): Kept the deprecated connection classes — + recorded as an Intentional Divergence (they still ship in 1.1.0 and carry the + `extMethod`/`extNotification` surface the fork's `_posthog/*` extensions rely on). +- **Elicitation fixes** (#774 d58004a, #779 b364059): Upstream's AskUserQuestion runs through + ACP's unstable elicitation API; ours uses its own `questions/` machinery behind the permission + flow and the renderer does not advertise elicitation. Same standing skip as the v0.44 sync. +- **ACP logout support** (#816, 0a0468c): Fork returns empty `authMethods` (auth handled + externally by PostHog); there is no CLI credential store to clear from the embedded agent. +- **Version flag handling** (#813, 9616bda): `src/index.ts` CLI-entrypoint concern; the fork is + embedded in the agent server and has no standalone binary. +- **Agent selection dropdown** (#794, 5729c47): Surfaces custom main-thread agent personas + (`supportedAgents()` minus built-ins) as an `agent` config option. PostHog Code drives its own + agent concepts; defer until product wants persona selection in the picker. +- **availableModels allowlist fixes** (#768 cc2885f, #827 98c284b) and **1M inference from model + descriptions** (#799, 508453c): All operate on upstream's SDK-settings model pipeline + (`ANTHROPIC_CUSTOM_MODEL_OPTION`, `modelOverrides`, `ModelInfo.description` scans). The fork's + models and context windows come from the PostHog gateway (`fetchGatewayModels`, + `getContextWindowForModel`), which has none of those inputs. +- **ReportFindings rendering** (#826): See above — no ReportFindings flow reaches the fork today; + the generic tool_call rendering is acceptable if it ever does. +- **`model_refusal_no_fallback` status subtype** (SDK 0.3.193, #818 5dd8746): Our + `handleSystemMessage` status handling is non-exhaustive, so the new subtype already no-ops + (same precedent as `thinking_tokens` / `model_refusal_fallback`). +- **Idle-time `usage_update`**: Dropped along with the #780 port (upstream removed it when turns + began settling at their terminal result). The mid-stream and result-time usage updates remain; + the idle-time emission double-counted cumulative loop usage in rare paths anyway. +- **Test-only upstream changes** (#769 41cde99 CLAUDE_CONFIG_DIR isolation, #792 9f38cb6 tmp + dirs): Upstream test-harness hygiene; our tests use their own fixtures. +- **Release / CI / dep-group bumps** (#772, #775, #778, #784, #788, #795, #802, #803, #808, + #811, #817, #821, #822, #823, #829, #831 and the pure SDK-bump commits #771, #783, #791, #798, + #806, #807, #810, #818 beyond the versions captured above): No fork-relevant code. ## Changes Ported in v0.44.0 Sync @@ -270,7 +380,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Next Sync -1. Check upstream changelog since v0.44.0 +1. Check upstream changelog since v0.54.1 2. Diff upstream source against PostHog Code using the file mapping above 3. Port in phases: bug fixes first, then features 4. After each phase: `pnpm --filter agent typecheck && pnpm --filter agent build && pnpm lint` diff --git a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts index e75287e18b..4222b68dd5 100644 --- a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts @@ -128,9 +128,10 @@ function installFakeSession( }, sessionResources: new Set(), configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, cwd: "/tmp/repo", notificationHistory: [{ foo: "bar" }], taskRunId: "run-1", @@ -201,7 +202,9 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => { it("rejects refresh while a prompt is in flight", async () => { const agent = makeAgent(); const { session } = installFakeSession(agent, "s-1"); - (session as unknown as { promptRunning: boolean }).promptRunning = true; + (session as unknown as { turnQueue: unknown[] }).turnQueue = [ + { promptUuid: "u-1", settled: false }, + ]; await expect( agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, { diff --git a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 850002be2c..0e9f422a08 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -62,9 +62,10 @@ function installFakeSession( }, sessionResources: new Set(), configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, cwd: "/tmp/repo", notificationHistory: [] as unknown[], taskRunId: "run-1", @@ -184,11 +185,8 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { expect(text).toContain(tc.commandInMessage); } } else { - // No unsupported chunk; loop falls through to the existing - // "Session did not end in result" failure path. - await expect(promptPromise).rejects.toThrow( - /Session did not end in result/, - ); + // Idle absorbed; the stream then ends before the turn ever starts. + await expect(promptPromise).rejects.toThrow(/session has ended/); expect( findUnsupportedChunkText(client.sessionUpdate.mock.calls), ).toBeUndefined(); @@ -205,6 +203,21 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { vi.clearAllMocks(); }); + function echoQueuedTurn(agent: Agent, query: MockQuery): void { + const turn = ( + agent as unknown as { + session: { turnQueue: Array<{ promptUuid: string }> }; + } + ).session.turnQueue[0]; + query._mockHelpers.sendMessage({ + type: "user", + uuid: turn.promptUuid, + session_id: "s", + parent_tool_use_id: null, + message: { role: "user", content: "echo" }, + } as unknown as SDKMessage); + } + it("returns 'cancelled' when the SDK never yields after interrupt (issue #680)", async () => { const { agent } = makeAgent(); const sessionId = "s-wedged"; @@ -217,6 +230,9 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { prompt: [{ type: "text", text: "do something slow" }], }); + await new Promise((resolve) => setImmediate(resolve)); + // cancel() only arms the backstop for an activated (echoed) turn. + echoQueuedTurn(agent, query); await new Promise((resolve) => setImmediate(resolve)); await agent.cancel({ sessionId }); @@ -228,7 +244,7 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { it("clears the backstop timer on a healthy cancel (interrupt yields)", async () => { const { agent } = makeAgent(); const sessionId = "s-healthy"; - installFakeSession(agent, sessionId); + const query = installFakeSession(agent, sessionId); (agent as unknown as { forceCancelGraceMs: number }).forceCancelGraceMs = 50_000; @@ -237,6 +253,8 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { prompt: [{ type: "text", text: "do something" }], }); await new Promise((resolve) => setImmediate(resolve)); + echoQueuedTurn(agent, query); + await new Promise((resolve) => setImmediate(resolve)); await agent.cancel({ sessionId }); @@ -247,4 +265,29 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { .forceCancelTimer, ).toBeUndefined(); }); + + it("settles a still-queued turn immediately on cancel", async () => { + const { agent } = makeAgent(); + const sessionId = "s-queued"; + const query = installFakeSession(agent, sessionId); + query.interrupt.mockImplementation(async () => {}); + + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "never echoed" }], + }); + await new Promise((resolve) => setImmediate(resolve)); + + await agent.cancel({ sessionId }); + + const result = await promptPromise; + expect(result.stopReason).toBe("cancelled"); + const session = ( + agent as unknown as { + session: { pendingOrphanResults: number; turnQueue: unknown[] }; + } + ).session; + expect(session.turnQueue).toHaveLength(0); + expect(session.pendingOrphanResults).toBe(1); + }); }); diff --git a/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts b/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts index b7f8d0a201..96775fa040 100644 --- a/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts @@ -66,9 +66,10 @@ function installFakeSession( }, sessionResources: new Set(), configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, cwd: "/tmp/repo", notificationHistory: [] as unknown[], taskRunId: "run-1", diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 7f2db920f0..4385dc728d 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -32,6 +32,8 @@ import { } from "@agentclientprotocol/sdk"; import { type CanUseTool, + type FastModeState, + getSessionInfo, getSessionMessages, listSessions, type McpSdkServerConfigWithInstance, @@ -112,10 +114,12 @@ import { import { DEFAULT_EFFORT, DEFAULT_MODEL, + fastModeStateEnabled, getEffortOptions, resolveEffortForModel, resolveModelPreference, supports1MContext, + supportsFastMode, supportsMcpInjection, toSdkModelId, } from "./session/models"; @@ -140,6 +144,7 @@ import type { ToolUpdateMeta, ToolUseCache, ToolUseStreamCache, + Turn, } from "./types"; const SESSION_VALIDATION_TIMEOUT_MS = 30_000; @@ -150,6 +155,9 @@ const MCP_STATUS_TIMEOUT_MS = 5_000; const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000; +const SESSION_ENDED_MESSAGE = + "The Claude Agent session has ended. Please start a new session."; + const MAX_TITLE_LENGTH = 256; const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); @@ -245,6 +253,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { readonly adapterName = "claude"; declare session: Session; toolUseCache: ToolUseCache; + /** Tool_use ids already surfaced as a `tool_call` (permission requests emit + * eagerly); the second emitter refines instead of duplicating. */ + emittedToolCalls: Set; toolUseStreamCache: ToolUseStreamCache; backgroundTerminals: { [key: string]: BackgroundTerminal } = {}; clientCapabilities?: ClientCapabilities; @@ -257,6 +268,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { super(client); this.options = options; this.toolUseCache = {}; + this.emittedToolCalls = new Set(); this.toolUseStreamCache = new Map(); this.logger = new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" }); this.enrichment = createEnrichment(options?.posthogApiConfig, this.logger); @@ -437,7 +449,6 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const userMessage = promptToClaude(params); const promptUuid = randomUUID(); userMessage.uuid = promptUuid; - let promptReplayed = false; let isLocalOnlyCommand = false; // Detect local-only slash commands that return results without model invocation @@ -456,65 +467,117 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const commandMatch = firstTextPart.match(/^(\/\S+)/); if (commandMatch && LOCAL_ONLY_COMMANDS.has(commandMatch[1])) { isLocalOnlyCommand = true; - promptReplayed = true; } if (commandMatch && !isLocalOnlyCommand) { await this.refreshSlashCommandsForPrompt(commandMatch[1]); } - if (this.session.promptRunning) { - const isSteer = isSteerMeta(params._meta); - if (isSteer) { - // Fold this message into the turn already running instead of queueing a - // new turn. promptToClaude tagged it priority:"next" so the SDK delivers - // it at the next tool-call boundary. Return immediately with a benign - // end_turn: the in-flight turn (not this call) owns the loop and the - // real stop reason. The client tells steers apart by the request's - // _meta.steer, not by this value. - this.session.input.push(userMessage); - await this.broadcastUserMessage(params); - return { stopReason: "end_turn" }; - } + if (this.session.queryClosed) { + throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE); + } + + const hasInFlightTurns = + this.session.activeTurn !== null || this.session.turnQueue.length > 0; + + if (hasInFlightTurns && isSteerMeta(params._meta)) { + // Fold into the running turn (promptToClaude tagged it priority:"next"); + // the benign end_turn is ignored by clients, which key off _meta.steer. this.session.input.push(userMessage); - const order = this.session.nextPendingOrder++; - const cancelled = await new Promise((resolve) => { - this.session.pendingMessages.set(promptUuid, { resolve, order }); - }); - if (cancelled) { - return { stopReason: "cancelled" }; - } - promptReplayed = true; - } else { + await this.broadcastUserMessage(params); + return { stopReason: "end_turn" }; + } + + if (!hasInFlightTurns && !isLocalOnlyCommand) { // Reconnect the signed-commit server before the turn (guard hook backstops). - if (!isLocalOnlyCommand) { - await this.ensureLocalToolsConnected("pre-prompt"); - } - this.session.input.push(userMessage); + await this.ensureLocalToolsConnected("pre-prompt"); + } + + if (this.session.lastContextWindowSize == null) { + this.session.lastContextWindowSize = this.getContextWindowForModel( + this.session.modelId ?? "", + ); + this.logger.debug("Initial context window size from gateway", { + modelId: this.session.modelId, + contextWindowSize: this.session.lastContextWindowSize, + }); + } + + const turn: Turn = { + promptUuid, + isLocalOnlyCommand, + commandName: commandMatch?.[1], + broadcast: () => this.broadcastUserMessage(params), + settled: false, + resolve: () => {}, + reject: () => {}, + }; + const response = new Promise((resolve, reject) => { + turn.resolve = resolve; + turn.reject = reject; + }); + + this.session.turnQueue.push(turn); + this.session.input.push(userMessage); + this.ensureConsumer(params.sessionId); + return response; + } + + private ensureConsumer(sessionId: string): void { + const session = this.session; + if (session.consumer) { + return; } + session.cancelController = new AbortController(); + session.consumer = this.runConsumer(session, sessionId); + session.consumer.catch((error) => { + this.logger.error("Consumer terminated unexpectedly", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } - // Reset session state here (after the queued-wait) rather than at the - // top of prompt(). Otherwise a new prompt() call would wipe cancelled=true - // on the previous still-running loop, causing it to return end_turn - // instead of the cancelled stop reason the spec requires. - this.session.cancelled = false; - this.session.interruptReason = undefined; - this.session.accumulatedUsage = { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, + private cancelledResponse(): PromptResponse { + return { + stopReason: "cancelled", + _meta: this.session.interruptReason + ? { interruptReason: this.session.interruptReason } + : undefined, }; - // sessionResources is intentionally NOT reset here — the products list - // accumulates across the whole session and is deduped, not per-turn. + } - await this.broadcastUserMessage(params); + /** Idempotent teardown once the query iterator is unrevivable. */ + private closeQueryStream(session: Session): void { + session.queryClosed = true; + session.consumer = undefined; + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + session.cancelController = undefined; + session.settingsManager.dispose(); + session.input.end(); + this.toolUseStreamCache.clear(); + this.emittedToolCalls.clear(); + } - this.session.promptRunning = true; - const cancelController = new AbortController(); - this.session.cancelController = cancelController; - let handedOff = false; - let errored = false; + /** Long-lived consumer of the session's SDK query stream: forwards every + * message (including between-turn output) and settles Turn deferreds. */ + private async runConsumer( + session: Session, + sessionId: string, + ): Promise { + // refreshSession swaps query/input in place and bumps the generation; a + // retired consumer must exit without tearing the refreshed session down. + const query = session.query; + const generation = session.queryGeneration; + const refreshed = () => + this.session !== session || + session.query !== query || + session.queryGeneration !== generation; + + // Per-turn scratch, reset on activation. let lastAssistantTotalUsage: number | null = null; let lastRefusalExplanation: string | null = null; let lastStreamUsage = { @@ -530,16 +593,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // `compacting` status sets it again, so every distinct compaction (e.g. // repeated auto-compactions in a long turn) is still shown. let compactionInProgress = false; - if (this.session.lastContextWindowSize == null) { - this.session.lastContextWindowSize = this.getContextWindowForModel( - this.session.modelId ?? "", - ); - this.logger.debug("Initial context window size from gateway", { - modelId: this.session.modelId, - contextWindowSize: this.session.lastContextWindowSize, - }); - } - let lastContextWindowSize = this.session.lastContextWindowSize; + let stopReason: PromptResponse["stopReason"] = "end_turn"; + + // Read live: model switches reset session.lastContextWindowSize. + const windowSize = () => + this.session.lastContextWindowSize ?? + this.getContextWindowForModel(this.session.modelId ?? ""); const supportsTerminalOutput = ( @@ -549,89 +608,234 @@ export class ClaudeAcpAgent extends BaseAcpAgent { )?.terminal_output === true; const context = { - session: this.session, - sessionId: params.sessionId, + session, + sessionId, client: this.client, toolUseCache: this.toolUseCache, + emittedToolCalls: this.emittedToolCalls, toolUseStreamCache: this.toolUseStreamCache, fileContentCache: this.fileContentCache, enrichedReadCache: this.enrichedReadCache, logger: this.logger, supportsTerminalOutput, - streamedAssistantBlocks: { - textIds: new Set(), - thinkingIds: new Set(), - }, + // Consumer-lived: turn activation can fire mid-message, so this must + // not reset per turn (it is cleared per message instead). + streamedAssistantBlocks: { blocks: [] }, }; + const sessionUsage = (): Usage => { + const acc = session.accumulatedUsage; + return { + inputTokens: acc.inputTokens, + outputTokens: acc.outputTokens, + cachedReadTokens: acc.cachedReadTokens, + cachedWriteTokens: acc.cachedWriteTokens, + totalTokens: + acc.inputTokens + + acc.outputTokens + + acc.cachedReadTokens + + acc.cachedWriteTokens, + }; + }; + + const resetTurnScratch = () => { + lastAssistantTotalUsage = null; + lastRefusalExplanation = null; + lastStreamUsage = { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }; + compactionInProgress = false; + stopReason = "end_turn"; + // sessionResources is intentionally NOT reset — the products list + // accumulates across the whole session and is deduped, not per-turn. + session.accumulatedUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; + }; + + const activateTurn = async (turn: Turn) => { + session.activeTurn = turn; + session.cancelled = false; + session.interruptReason = undefined; + session.pendingOrphanResults = 0; + resetTurnScratch(); + try { + await turn.broadcast(); + } catch (error) { + this.logger.warn("Failed to broadcast user message", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + // Promote the queue head for echo-less results (local-only commands, + // compaction), skipping any orphan results owed by cancelled-while-queued + // turns so they can't be misattributed to a later prompt. + const ensureActiveTurn = async () => { + if (session.activeTurn) { + return; + } + const head = session.turnQueue.find((t) => !t.settled); + if (!head) { + return; + } + if (session.pendingOrphanResults > 0) { + session.pendingOrphanResults--; + return; + } + await activateTurn(head); + }; + + const settleActive = (result: PromptResponse) => { + const turn = session.activeTurn; + if (!turn || turn.settled) { + return; + } + turn.settled = true; + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + session.turnQueue = session.turnQueue.filter((t) => t !== turn); + session.activeTurn = null; + turn.resolve(result); + }; + + // Reject the active turn without tearing down the consumer. + const failActive = (error: unknown) => { + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + const turn = session.activeTurn; + if (!turn || turn.settled) { + return; + } + turn.settled = true; + session.turnQueue = session.turnQueue.filter((t) => t !== turn); + session.activeTurn = null; + this.toolUseStreamCache.clear(); + turn.reject(error); + }; + + // Reject every in-flight turn when the stream dies. + const failAllTurns = (error: unknown) => { + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + const turns = session.activeTurn + ? [ + session.activeTurn, + ...session.turnQueue.filter((t) => t !== session.activeTurn), + ] + : [...session.turnQueue]; + session.activeTurn = null; + session.turnQueue = []; + this.toolUseStreamCache.clear(); + for (const turn of turns) { + if (!turn.settled) { + turn.settled = true; + turn.reject(error); + } + } + }; + + let cancelController = session.cancelController as AbortController; + try { while (true) { - const nextMessage = this.session.query.next(); + const nextMessage = query.next(); const next = await withAbort(nextMessage, cancelController.signal); if (next.result === "aborted" || cancelController.signal.aborted) { + // Abandon the in-flight next(), swallowing any later rejection. void nextMessage.catch((err) => this.logger.warn("in-flight query.next() rejected after cancel", { - sessionId: params.sessionId, + sessionId, error: err instanceof Error ? err.message : String(err), }), ); - return { - stopReason: "cancelled", - _meta: this.session.interruptReason - ? { interruptReason: this.session.interruptReason } - : undefined, - }; + settleActive(this.cancelledResponse()); + this.toolUseStreamCache.clear(); + if (refreshed() || session.queryClosed) { + return; + } + cancelController = new AbortController(); + session.cancelController = cancelController; + continue; } const { value: message, done } = next.value; if (done || !message) { - if (this.session.cancelled) { - return { - stopReason: "cancelled", - _meta: this.session.interruptReason - ? { interruptReason: this.session.interruptReason } - : undefined, - }; + if (refreshed()) { + return; } - break; + settleActive( + session.cancelled + ? this.cancelledResponse() + : { stopReason, usage: sessionUsage() }, + ); + // Queued turns the SDK never started produced no output; reject + // them rather than report a success. + for (const queued of [...session.turnQueue]) { + if (!queued.settled) { + queued.settled = true; + queued.reject( + RequestError.internalError(undefined, SESSION_ENDED_MESSAGE), + ); + } + } + session.turnQueue = []; + this.closeQueryStream(session); + return; } if ( - this.session.emitRawSDKMessages && - shouldEmitRawMessage(this.session.emitRawSDKMessages, message) + session.emitRawSDKMessages && + shouldEmitRawMessage(session.emitRawSDKMessages, message) ) { await this.client.extNotification("_claude/sdkMessage", { - sessionId: params.sessionId, + sessionId, message: message as Record, }); } switch (message.type) { case "system": + if (message.subtype === "init") { + await this.syncFastModeState(message.fast_mode_state); + } if (message.subtype === "compact_boundary") { + await ensureActiveTurn(); const usedTokens = await withAbort( - fetchContextUsedTokens(this.session.query, this.logger), + fetchContextUsedTokens(query, this.logger), cancelController.signal, ); lastAssistantTotalUsage = usedTokens.result === "success" ? (usedTokens.value ?? 0) : 0; - promptReplayed = true; await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), }, }); } if (message.subtype === "commands_changed") { - this.session.knownSlashCommands = collectKnownSlashCommands( + session.knownSlashCommands = collectKnownSlashCommands( message.commands, ); const available = getAvailableSlashCommands(message.commands); await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "available_commands_update", availableCommands: available, @@ -644,7 +848,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { break; } if (message.subtype === "local_command_output") { - promptReplayed = true; + await ensureActiveTurn(); } if (message.subtype === "status") { // The SDK signals manual `/compact` completion with a status @@ -663,7 +867,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ) { compactionInProgress = false; await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "agent_message_chunk", content: { @@ -678,7 +882,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { await this.client.extNotification( POSTHOG_NOTIFICATIONS.STATUS, { - sessionId: params.sessionId, + sessionId, status: "compacting", isComplete: true, }, @@ -696,7 +900,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { await this.client.extNotification( POSTHOG_NOTIFICATIONS.STATUS, { - sessionId: params.sessionId, + sessionId, status: "compacting_failed", error: message.compact_error ?? undefined, }, @@ -708,93 +912,91 @@ export class ClaudeAcpAgent extends BaseAcpAgent { message.subtype === "session_state_changed" && (message as Record).state === "idle" ) { - if (!promptReplayed) { - // The SDK consumed a slash command we do not handle locally - // and produced no output (e.g. /plugin in a non-interactive - // context). Without this branch we would loop forever waiting - // for an echo that never comes; surface a clear error instead. - // - // Only fire for commands the SDK does NOT recognize. Plugin - // and skill commands (e.g. /skills-store) produce a fresh - // user-message echo with a new uuid that our replay check - // can't match, so an early idle here is a race, not a real - // "unsupported" — fall through and let the loop continue. - const cmdName = commandMatch?.[1].slice(1); - const known = - cmdName !== undefined && - this.session.knownSlashCommands?.has(cmdName) === true; - if (commandMatch && !known) { - const cmd = commandMatch[1]; - this.logger.warn( - "Slash command produced no output; treating as unsupported", - { sessionId: params.sessionId, command: cmd }, - ); - await this.client.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `Unsupported slash command: \`${cmd}\`. PostHog Code does not implement this command.`, - }, - }, - }); - return { stopReason: "end_turn" }; + if (session.activeTurn) { + // Only a cancelled turn settles at idle; its result was + // dropped at the `session.cancelled` guard. + if (session.cancelled) { + settleActive(this.cancelledResponse()); } - this.logger.debug("Skipping idle state before prompt replay", { - sessionId: params.sessionId, - command: commandMatch?.[1], - known, + await this.maybeUpdateSessionTitle(sessionId, session); + break; + } + await this.maybeUpdateSessionTitle(sessionId, session); + // An unknown command the SDK consumed silently never echoes; + // known plugin/skill commands echo late (race, not unsupported). + const head = session.turnQueue.find((t) => !t.settled); + if ( + head?.commandName && + session.pendingOrphanResults === 0 && + session.knownSlashCommands?.has(head.commandName.slice(1)) !== + true + ) { + const cmd = head.commandName; + this.logger.warn( + "Slash command produced no output; treating as unsupported", + { sessionId, command: cmd }, + ); + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `Unsupported slash command: \`${cmd}\`. PostHog Code does not implement this command.`, + }, + }, }); + head.settled = true; + session.turnQueue = session.turnQueue.filter((t) => t !== head); + head.resolve({ stopReason: "end_turn" }); break; } - - const acc = this.session.accumulatedUsage; - const totalUsed = - acc.inputTokens + - acc.outputTokens + - acc.cachedReadTokens + - acc.cachedWriteTokens; - - await this.client.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "usage_update", - used: totalUsed, - size: lastContextWindowSize, - }, + this.logger.debug("Idle without an active turn", { + sessionId, + queuedTurns: session.turnQueue.length, + command: head?.commandName, }); - - return { - stopReason: this.session.cancelled ? "cancelled" : "end_turn", - }; + break; } await handleSystemMessage(message, context); break; case "result": { - // Skip results from background tasks that finished after our prompt started - if (!promptReplayed) { - this.logger.debug( - "Skipping background task result before prompt replay", - { sessionId: params.sessionId }, + // Task-notification followups are background work: they must not + // touch the user-turn lifecycle, but their cost is still reported. + const isTaskNotification = + (message as { origin?: { kind?: string } }).origin?.kind === + "task-notification"; + + if (!isTaskNotification) { + await this.syncFastModeState( + (message as { fast_mode_state?: FastModeState }) + .fast_mode_state, ); - break; } - if (this.session.cancelled) { - return { stopReason: "cancelled" }; + // Promote before accumulating usage: activation resets the + // accumulator. + if (!isTaskNotification) { + await ensureActiveTurn(); } - // Accumulate usage from this result (guard against null from SDK) - this.session.accumulatedUsage.inputTokens += - message.usage.input_tokens ?? 0; - this.session.accumulatedUsage.outputTokens += - message.usage.output_tokens ?? 0; - this.session.accumulatedUsage.cachedReadTokens += - message.usage.cache_read_input_tokens ?? 0; - this.session.accumulatedUsage.cachedWriteTokens += - message.usage.cache_creation_input_tokens ?? 0; + // A cancelled turn settles at idle (or the backstop) instead. + if (session.cancelled) { + break; + } + + if (!isTaskNotification) { + // Accumulate usage from this result (guard against null from SDK) + session.accumulatedUsage.inputTokens += + message.usage.input_tokens ?? 0; + session.accumulatedUsage.outputTokens += + message.usage.output_tokens ?? 0; + session.accumulatedUsage.cachedReadTokens += + message.usage.cache_read_input_tokens ?? 0; + session.accumulatedUsage.cachedWriteTokens += + message.usage.cache_creation_input_tokens ?? 0; + } // SDK can underreport context window (e.g. 200k for 1M models). // Use SDK value only if it's larger than what gateway reported. @@ -803,25 +1005,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ); if (contextWindows.length > 0) { const sdkContextWindow = Math.min(...contextWindows); - if (sdkContextWindow > lastContextWindowSize) { - lastContextWindowSize = sdkContextWindow; + if (sdkContextWindow > windowSize()) { + session.lastContextWindowSize = sdkContextWindow; } } - this.session.lastContextWindowSize = lastContextWindowSize; - this.session.contextSize = lastContextWindowSize; + session.contextSize = windowSize(); if (lastAssistantTotalUsage !== null) { - this.session.contextUsed = lastAssistantTotalUsage; + session.contextUsed = lastAssistantTotalUsage; } // Send usage_update notification if (lastAssistantTotalUsage !== null) { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), cost: { amount: message.total_cost_usd, currency: "USD", @@ -839,7 +1040,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { await this.client.extNotification( POSTHOG_NOTIFICATIONS.USAGE_UPDATE, { - sessionId: params.sessionId, + sessionId, used: { inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, @@ -848,42 +1049,38 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, cost: message.total_cost_usd, breakdown: buildBreakdown( - this.session.contextBreakdownBaseline ?? emptyBaseline(), + session.contextBreakdownBaseline ?? emptyBaseline(), breakdownInputTokens, ), }, ); - const usage: Usage = { - inputTokens: this.session.accumulatedUsage.inputTokens, - outputTokens: this.session.accumulatedUsage.outputTokens, - cachedReadTokens: this.session.accumulatedUsage.cachedReadTokens, - cachedWriteTokens: - this.session.accumulatedUsage.cachedWriteTokens, - totalTokens: - this.session.accumulatedUsage.inputTokens + - this.session.accumulatedUsage.outputTokens + - this.session.accumulatedUsage.cachedReadTokens + - this.session.accumulatedUsage.cachedWriteTokens, - }; - if ( (message as { stop_reason?: string }).stop_reason === "refusal" ) { if (lastRefusalExplanation) { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: lastRefusalExplanation }, }, }); } - return { stopReason: "refusal", usage }; + if (!isTaskNotification) { + stopReason = "refusal"; + settleActive({ stopReason: "refusal", usage: sessionUsage() }); + } + break; } const result = handleResultMessage(message); - if (result.error) throw result.error; + if (result.error) { + if (!isTaskNotification) { + failActive(result.error); + } + break; + } // Deliver structured output from SDK's native outputFormat if ( @@ -898,12 +1095,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // For local-only commands, forward the result text to the client if ( - isLocalOnlyCommand && + session.activeTurn?.isLocalOnlyCommand && + !isTaskNotification && message.subtype === "success" && message.result ) { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: message.result }, @@ -911,7 +1109,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } - return { stopReason: result.stopReason ?? "end_turn", usage }; + // Settle at the terminal result rather than the trailing idle, + // which can lag behind background work. + if (!isTaskNotification) { + stopReason = result.stopReason ?? "end_turn"; + settleActive({ stopReason, usage: sessionUsage() }); + } + break; } case "stream_event": { @@ -952,11 +1156,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (nextTotal !== lastAssistantTotalUsage) { lastAssistantTotalUsage = nextTotal; await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: nextTotal, - size: lastContextWindowSize, + size: windowSize(), }, }); } @@ -967,31 +1171,41 @@ export class ClaudeAcpAgent extends BaseAcpAgent { case "user": case "assistant": { - // Check for prompt replay (our own message echoed back) + // A user echo promotes its queued turn (handing off any still- + // active one first), then drops from the feed. Runs before the + // cancelled guard so a turn enqueued after a cancel still starts. if (message.type === "user" && "uuid" in message && message.uuid) { - if (message.uuid === promptUuid) { - promptReplayed = true; + const queued = session.turnQueue.find( + (t) => t.promptUuid === message.uuid && !t.settled, + ); + if (queued) { + // A turn promoted early by its result must not have its + // usage reset by its own echo. + if (session.activeTurn !== queued) { + if (session.activeTurn) { + settleActive( + session.cancelled + ? this.cancelledResponse() + : { stopReason: "end_turn", usage: sessionUsage() }, + ); + } + await activateTurn(queued); + } break; } - - const pending = this.session.pendingMessages.get( - message.uuid as string, - ); - if (pending) { - pending.resolve(false); - this.session.pendingMessages.delete(message.uuid as string); - handedOff = true; - return { - stopReason: this.session.cancelled ? "cancelled" : "end_turn", - }; + if ( + "isReplay" in message && + (message as Record).isReplay + ) { + break; } } - if (this.session.cancelled) { + if (session.cancelled) { break; } - // Skip replayed user messages that aren't pending prompts + // Skip replayed messages that aren't queued prompts if ( "isReplay" in message && (message as Record).isReplay @@ -1035,27 +1249,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent { (usage.cache_creation_input_tokens ?? 0); await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), cost: null, }, }); } const result = await handleUserAssistantMessage(message, context); - if (result.error) throw result.error; + if (result.error) { + failActive(result.error); + break; + } if (result.shouldStop) { - return { stopReason: "end_turn" }; + settleActive({ stopReason: "end_turn" }); } break; } case "tool_progress": { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: message.tool_use_id, @@ -1075,11 +1292,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { case "rate_limit_event": { if (lastAssistantTotalUsage !== null) { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), _meta: { "_claude/rateLimit": message.rate_limit_info }, }, }); @@ -1096,116 +1313,71 @@ export class ClaudeAcpAgent extends BaseAcpAgent { break; } } - throw new Error("Session did not end in result"); } catch (error) { - errored = true; - // A failed turn typically leaves a trailing `session_state_changed: idle` - // (and possibly more) in the query iterator. If we don't drain it here, - // the next prompt's first `query.next()` consumes that stale idle and - // short-circuits to end_turn with zero usage. - try { - await this.session.query.interrupt(); - const MAX_DRAIN = 100; - for (let i = 0; i < MAX_DRAIN; i++) { - const { value: m, done } = await this.session.query.next(); - if (done || !m) break; - if ( - m.type === "system" && - m.subtype === "session_state_changed" && - (m as Record).state === "idle" - ) { - break; - } - if (i === MAX_DRAIN - 1) { - this.logger.error( - `Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`, - ); - } - } - } catch (drainErr) { - this.logger.error( - `Session ${params.sessionId}: failed to drain query after prompt error`, - { error: drainErr }, - ); - } - - if (error instanceof RequestError || !(error instanceof Error)) { - throw error; + // Only stream-level errors reach here; turn-level failures were + // rejected inline via failActive. + if (refreshed()) { + this.logger.debug("Consumer for a refreshed query exiting on error", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return; } - const msg = error.message; - if ( - msg.includes("ProcessTransport") || - msg.includes("terminated process") || - msg.includes("process exited with") || - msg.includes("process terminated by signal") || - msg.includes("Failed to write to process stdin") - ) { + const msg = error instanceof Error ? error.message : String(error); + const processDied = + error instanceof Error && + (msg.includes("ProcessTransport") || + msg.includes("terminated process") || + msg.includes("process exited with") || + msg.includes("process terminated by signal") || + msg.includes("Failed to write to process stdin")); + if (processDied) { this.logger.error(`Process died: ${msg}`, { sessionId: this.sessionId, }); - this.session.settingsManager.dispose(); - this.session.input.end(); - throw RequestError.internalError( - { details: msg }, - "The Claude Agent process exited unexpectedly. Please start a new session.", + failAllTurns( + RequestError.internalError( + { details: msg }, + "The Claude Agent process exited unexpectedly. Please start a new session.", + ), ); + } else { + this.logger.error("Query stream error", { sessionId, error: msg }); + failAllTurns(error); } - throw error; - } finally { - if (this.session.forceCancelTimer) { - clearTimeout(this.session.forceCancelTimer); - this.session.forceCancelTimer = undefined; - } - if (this.session.cancelController === cancelController) { - this.session.cancelController = undefined; - } - // Drop any leftover streaming-input buffers. Normally cleared per index - // on `content_block_stop`, but a cancelled or errored turn may leave - // entries behind; without this they'd carry over into the next turn - // and collide with new content-block indices. - this.toolUseStreamCache.clear(); - if (!handedOff) { - this.session.promptRunning = false; - if (errored) { - // The query stream was just drained — handing pending prompts off - // onto it would let them race with the recovery. Cancel them so - // each waiting prompt() returns stopReason "cancelled" and the - // client can decide whether to retry. - for (const pending of this.session.pendingMessages.values()) { - pending.resolve(true); - } - this.session.pendingMessages.clear(); - } else if (this.session.pendingMessages.size > 0) { - // Clean exit with queued prompts: hand off the lowest-order one - // so it can proceed. The rest stay queued for their own turn. - const next = [...this.session.pendingMessages.entries()].sort( - (a, b) => a[1].order - b[1].order, - )[0]; - if (next) { - next[1].resolve(false); - this.session.pendingMessages.delete(next[0]); - } - } - } + this.closeQueryStream(session); } } // Called by BaseAcpAgent#cancel() to interrupt the session protected async interrupt(): Promise { - this.session.cancelled = true; - for (const [, pending] of this.session.pendingMessages) { - pending.resolve(true); + const session = this.session; + if (session.queryClosed) { + return; } - this.session.pendingMessages.clear(); + session.cancelled = true; + // Settle not-yet-echoed turns immediately; the SDK still runs their + // pushed messages, so count the echo-less results they owe as orphans. + for (const turn of [...session.turnQueue]) { + if (turn === session.activeTurn || turn.settled) { + continue; + } + turn.settled = true; + session.turnQueue = session.turnQueue.filter((t) => t !== turn); + session.pendingOrphanResults += 1; + turn.resolve(this.cancelledResponse()); + } + + // Backstop for an SDK that never yields after interrupt() (issue #680). if ( - this.session.promptRunning && - this.session.cancelController && - !this.session.cancelController.signal.aborted && - !this.session.forceCancelTimer + session.activeTurn && + session.cancelController && + !session.cancelController.signal.aborted && + !session.forceCancelTimer ) { - const cancelController = this.session.cancelController; - this.session.forceCancelTimer = setTimeout(() => { + const cancelController = session.cancelController; + session.forceCancelTimer = setTimeout(() => { this.logger.error( `Session ${this.sessionId}: cancel floor elapsed without the SDK yielding; forcing "cancelled". The underlying query may still be wedged — a new session may be required.`, ); @@ -1213,7 +1385,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, this.forceCancelGraceMs); } - await this.session.query.interrupt(); + await session.query.interrupt(); } /** @@ -1272,7 +1444,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { mcpServers: Record, ): Promise { const prev = this.session; - if (prev.promptRunning) { + if (prev.activeTurn !== null || prev.turnQueue.length > 0) { throw new RequestError( -32002, "Cannot refresh session while a prompt turn is in flight", @@ -1290,6 +1462,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionId: this.sessionId, }); + // Retire the old consumer: the generation bump makes it exit quietly. + prev.queryGeneration += 1; + const oldConsumer = prev.consumer; + prev.consumer = undefined; + prev.cancelController?.abort(); + prev.cancelController = undefined; + // Abort FIRST so any stuck in-flight HTTP request unblocks — otherwise // interrupt() can deadlock waiting on an API call that never returns. // We allocate a fresh controller for the new Query below so aborting @@ -1304,6 +1483,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } prev.input.end(); + if (oldConsumer) { + // Bounded so a wedged old query can't block the refresh. + await withTimeout(oldConsumer, 5_000); + } // Reuse every option from the running session; swap mcpServers, re-root // identity on `resume` instead of `sessionId`, and give the new Query a @@ -1494,6 +1677,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.session.lastContextWindowSize = this.getContextWindowForModel(resolvedValue); this.rebuildEffortConfigOption(resolvedValue); + this.rebuildFastModeConfigOption(resolvedValue); } else if (params.configId === "effort") { const newEffort = resolvedValue as EffortLevel; this.session.effort = newEffort; @@ -1502,6 +1686,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // @ts-expect-error SDK Settings.effortLevel omits "max" but runtime accepts it effortLevel: newEffort, }); + } else if (params.configId === "fast") { + // SDK flag first: a rejected control request leaves state untouched. + const enabled = resolvedValue === "on"; + await this.session.query.applyFlagSettings({ fastMode: enabled }); + this.session.fastModeEnabled = enabled; } this.session.configOptions = this.session.configOptions.map((o) => @@ -1793,9 +1982,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionResources: new Set(), effort, configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, + fastModeEnabled: false, emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages ?? false, contextBreakdownBaseline: { ...emptyBaseline(), @@ -1809,6 +2000,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { notificationHistory: [], taskRunId: meta?.taskRunId, }; + // A replaced session's consumer never reaches closeQueryStream. + this.emittedToolCalls.clear(); this.session = session; this.sessionId = sessionId; @@ -1830,6 +2023,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { session.knownSlashCommands = collectKnownSlashCommands( result.value.commands, ); + session.fastModeEnabled = fastModeStateEnabled( + result.value.fast_mode_state, + ); } catch (err) { settingsManager.dispose(); this.terminateQuery(q, abortController); @@ -1899,6 +2095,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { session.knownSlashCommands = collectKnownSlashCommands( initResult.value.commands, ); + session.fastModeEnabled = fastModeStateEnabled( + initResult.value.fast_mode_state, + ); this.logger.info("Session initialized", { sessionId, taskId, @@ -1969,6 +2168,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { permissionMode, modelOptions, this.session.effort ?? DEFAULT_EFFORT, + session.fastModeEnabled, ); session.configOptions = configOptions; @@ -1999,6 +2199,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.updateConfigOption(configId, value), applySessionMode: (modeId: string) => this.applySessionMode(modeId), allowedDomains, + emittedToolCalls: this.emittedToolCalls, + supportsTerminalOutput: + ( + this.clientCapabilities?._meta as + | ClientCapabilities["_meta"] + | undefined + )?.terminal_output === true, }); } @@ -2075,6 +2282,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { options: SessionConfigSelectOption[]; }, currentEffort: EffortLevel = DEFAULT_EFFORT, + fastModeEnabled?: boolean, ): SessionConfigOption[] { const modeOptions = getAvailableModes().map((mode) => ({ value: mode.id, @@ -2117,9 +2325,95 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } + if (supportsFastMode(modelOptions.currentModelId)) { + configOptions.push(this.fastModeConfigOption(fastModeEnabled ?? false)); + } + return configOptions; } + private fastModeConfigOption(enabled: boolean): SessionConfigOption { + return { + id: "fast", + name: "Fast mode", + type: "select", + currentValue: enabled ? "on" : "off", + options: [ + { value: "on", name: "On" }, + { value: "off", name: "Off" }, + ], + description: "Faster responses on supported models", + }; + } + + private rebuildFastModeConfigOption(modelId: string): void { + const withoutFast = this.session.configOptions.filter( + (o) => o.id !== "fast", + ); + this.session.configOptions = supportsFastMode(modelId) + ? [ + ...withoutFast, + this.fastModeConfigOption(this.session.fastModeEnabled), + ] + : withoutFast; + } + + // Mirror SDK-reported fast mode flips into the config option. A hidden + // option means the state reflects capability, not intent, and cooldown is + // transient; neither may touch the retained toggle. + private async syncFastModeState( + state: FastModeState | undefined, + ): Promise { + if (state === undefined || state === "cooldown") { + return; + } + if (!this.session.configOptions.some((o) => o.id === "fast")) { + return; + } + const enabled = state === "on"; + if (enabled === this.session.fastModeEnabled) { + return; + } + this.session.fastModeEnabled = enabled; + await this.updateConfigOption("fast", enabled ? "on" : "off"); + } + + // The SDK has no push event for the title it generates in the background, + // so poll it at turn-end; failures are non-fatal and retried next turn. + private async maybeUpdateSessionTitle( + sessionId: string, + session: Session, + ): Promise { + let info: Awaited>; + try { + info = await getSessionInfo(sessionId, { dir: session.cwd }); + } catch (error) { + this.logger.warn("Failed to read session info for title update", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + // customTitle is a user rename; prefer it over the generated summary. + const rawTitle = info?.customTitle ?? info?.summary; + if (!rawTitle) { + return; + } + const title = sanitizeTitle(rawTitle); + if (!title || title === session.lastTitle) { + return; + } + session.lastTitle = title; + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "session_info_update", + title, + updatedAt: new Date(info?.lastModified ?? Date.now()).toISOString(), + }, + }); + } + private rebuildEffortConfigOption(modelId: string): void { const effortOptions = getEffortOptions(modelId); const existingEffort = this.session.configOptions.find( @@ -2266,6 +2560,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionId, client: this.client, toolUseCache: this.toolUseCache, + emittedToolCalls: this.emittedToolCalls, toolUseStreamCache: this.toolUseStreamCache, fileContentCache: this.fileContentCache, enrichedReadCache: this.enrichedReadCache, diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts index 1b30a17d1e..60a64e1863 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts @@ -81,10 +81,7 @@ function createHandlerContext() { toolUseStreamCache: new Map(), fileContentCache: {}, logger: new Logger({ debug: false }), - streamedAssistantBlocks: { - textIds: new Set(), - thinkingIds: new Set(), - }, + streamedAssistantBlocks: { blocks: [] }, }; return { context, updates }; } @@ -186,7 +183,7 @@ describe("assembled assistant text fallback", () => { ]); }); - it("tracks streamed ids per message so a later message still falls back", async () => { + it("tracks streamed content per message so a later message still falls back", async () => { const { context, updates } = createHandlerContext(); await streamLiveText(context, "msg_1", "streamed"); updates.length = 0; @@ -199,6 +196,78 @@ describe("assembled assistant text fallback", () => { ]); }); + it.each([ + { + label: "forwards only the un-streamed tail when the stream was cut short", + streams: [["msg_1", "hello wor"]] as const, + assembled: { id: "msg_1", text: "hello world" }, + expected: ["ld"], + }, + { + label: + "dedupes by content when the consolidated message id differs from the stream", + streams: [["msg_gateway_1", "same text"]] as const, + assembled: { id: "msg_other_id", text: "same text" }, + expected: [], + }, + { + // msg_1 never got its consolidated message (e.g. cancelled turn); + // its residue must not swallow or truncate msg_2's block. + label: "clears streamed residue when a new top-level message starts", + streams: [ + ["msg_1", "cancelled turn text"], + ["msg_2", "cancelled"], + ] as const, + assembled: { id: "msg_2", text: "cancelled turn" }, + expected: [" turn"], + }, + ])("$label", async ({ streams, assembled, expected }) => { + const { context, updates } = createHandlerContext(); + for (const [apiId, text] of streams) { + await streamLiveText(context, apiId, text); + } + updates.length = 0; + await handleUserAssistantMessage( + assistantMessage(assembled.id, [{ type: "text", text: assembled.text }]), + context, + ); + expect(chunkTexts(updates, "agent_message_chunk")).toEqual(expected); + }); + + it("ignores empty streamed deltas so they cannot stall the diff cursor", async () => { + const { context, updates } = createHandlerContext(); + await handleStreamEvent( + streamEvent({ type: "message_start", message: { id: "msg_1" } }), + context, + ); + await handleStreamEvent( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "" }, + }), + context, + ); + await handleStreamEvent( + streamEvent({ + type: "content_block_delta", + index: 1, + delta: { type: "text_delta", text: "answer" }, + }), + context, + ); + updates.length = 0; + await handleUserAssistantMessage( + assistantMessage("msg_1", [ + { type: "thinking", thinking: "" }, + { type: "text", text: "answer" }, + ]), + context, + ); + expect(chunkTexts(updates, "agent_message_chunk")).toEqual([]); + expect(chunkTexts(updates, "agent_thought_chunk")).toEqual([]); + }); + it("drops empty assembled blocks", async () => { const { context, updates } = createHandlerContext(); await handleUserAssistantMessage( diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index fe4197dacb..8c90661fd9 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -66,6 +66,9 @@ interface AnthropicMessageWithContent { type ChunkHandlerContext = { sessionId: string; toolUseCache: ToolUseCache; + /** Tool_use ids already surfaced as a `tool_call` (permission requests emit + * eagerly); the second emitter refines instead of duplicating. */ + emittedToolCalls?: Set; fileContentCache: { [key: string]: string }; enrichedReadCache?: EnrichedReadCache; client: AgentSideConnection; @@ -81,19 +84,14 @@ type ChunkHandlerContext = { }; /** - * Per-turn record of which top-level assistant message ids actually streamed - * text/thinking live via `stream_event` deltas. The consolidated assistant - * message normally has its text/thinking blocks dropped as duplicates of the - * streamed chunks, but gateways that return a turn as a single non-streamed - * block (common with OpenAI-compatible proxies) never fire deltas, so the - * assembled block is the only copy the client will ever see. Tracked per - * block type so a gateway that streams text but not thinking (or vice versa) - * doesn't lose the un-streamed block. + * Text/thinking actually streamed live for the in-flight message, in order. + * The consolidated assistant message prefix-diffs its blocks against this and + * forwards only the un-streamed remainder. Content matching (not message ids) + * keeps dedupe robust to gateways whose ids don't line up; cleared per + * message so it stays bounded. */ export interface StreamedAssistantBlocks { - currentStreamMessageId?: string; - textIds: Set; - thinkingIds: Set; + blocks: { index: number; type: "text" | "thinking"; text: string }[]; } export interface MessageHandlerContext { @@ -101,6 +99,8 @@ export interface MessageHandlerContext { sessionId: string; client: AgentSideConnection; toolUseCache: ToolUseCache; + /** See `ChunkHandlerContext.emittedToolCalls`. */ + emittedToolCalls?: Set; /** Buffers `input_json_delta` partial JSON per content-block index. */ toolUseStreamCache: ToolUseStreamCache; fileContentCache: { [key: string]: string }; @@ -177,7 +177,12 @@ function handleImageChunk( function handleThinkingChunk( chunk: { thinking: string }, parentToolCallId?: string, -): SessionUpdate { +): SessionUpdate | null { + // Recent models default `thinking.display` to "omitted", which streams + // signature-only thinking blocks whose text is empty. + if (chunk.thinking.length === 0) { + return null; + } const update: SessionUpdate = { sessionUpdate: "agent_thought_chunk", content: text(chunk.thinking), @@ -197,6 +202,8 @@ function handleToolUseChunk( ctx: ChunkHandlerContext, ): SessionUpdate | null { const alreadyCached = chunk.id in ctx.toolUseCache; + const alreadyEmitted = + alreadyCached || ctx.emittedToolCalls?.has(chunk.id) === true; ctx.toolUseCache[chunk.id] = chunk; // Suppress Task* tool_calls — plan updates are emitted from the matching @@ -209,6 +216,7 @@ function handleToolUseChunk( ) { return null; } + ctx.emittedToolCalls?.add(chunk.id); if (!alreadyCached && ctx.registerHooks !== false) { const toolName = chunk.name; @@ -260,11 +268,11 @@ function handleToolUseChunk( bashCommandFromToolUse(chunk), ), }; - if (chunk.name === "Bash" && ctx.supportsTerminalOutput && !alreadyCached) { + if (chunk.name === "Bash" && ctx.supportsTerminalOutput && !alreadyEmitted) { meta.terminal_info = { terminal_id: chunk.id }; } - if (alreadyCached) { + if (alreadyEmitted) { return { _meta: meta, toolCallId: chunk.id, @@ -361,6 +369,7 @@ function handleToolResultChunk( } delete ctx.toolUseCache[chunk.tool_use_id]; + ctx.emittedToolCalls?.delete(chunk.tool_use_id); if ( toolUse.name === "TaskCreate" || @@ -544,6 +553,7 @@ function toAcpNotifications( mcpToolUseResult?: Record, enrichedReadCache?: EnrichedReadCache, taskState?: TaskState, + emittedToolCalls?: Set, ): SessionNotification[] { if (typeof content === "string") { const update: SessionUpdate = { @@ -563,6 +573,7 @@ function toAcpNotifications( const ctx: ChunkHandlerContext = { sessionId, toolUseCache, + emittedToolCalls, fileContentCache, enrichedReadCache, client, @@ -599,6 +610,7 @@ function streamEventToAcpNotifications( cwd?: string, enrichedReadCache?: EnrichedReadCache, taskState?: TaskState, + emittedToolCalls?: Set, ): SessionNotification[] { const event = message.event; switch (event.type) { @@ -625,6 +637,7 @@ function streamEventToAcpNotifications( undefined, enrichedReadCache, taskState, + emittedToolCalls, ); } case "content_block_delta": { @@ -651,6 +664,7 @@ function streamEventToAcpNotifications( undefined, enrichedReadCache, taskState, + emittedToolCalls, ); } case "content_block_stop": @@ -796,6 +810,22 @@ export async function handleSystemMessage( `Session ${sessionId}: failed to persist history: ${message.error}`, ); break; + case "informational": { + // Surface hook-blocked stops; the level is folded into the text since + // agent_message_chunk has no severity field. + const informationalText = + message.level === "info" + ? message.content + : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`; + await client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: informationalText }, + }, + }); + break; + } case "permission_denied": { const reason = message.decision_reason ?? message.message; await client.sessionUpdate({ @@ -954,20 +984,36 @@ export async function handleStreamEvent( const streamed = context.streamedAssistantBlocks; if (streamed) { - if (message.event.type === "message_start") { - streamed.currentStreamMessageId = message.event.message.id || undefined; + // Clear residue from a message that never reached its consolidated reset + // (e.g. a cancelled turn); indices restart per message and would collide. + if ( + message.event.type === "message_start" && + message.parent_tool_use_id === null + ) { + streamed.blocks.length = 0; } - // Only top-level streams are recorded — subagent text is never streamed - // and must stay filtered, as it is internal to the tool call. + // Record only top-level streams; subagent text is never streamed and + // must stay filtered. if ( - streamed.currentStreamMessageId && message.parent_tool_use_id === null && message.event.type === "content_block_delta" ) { - if (message.event.delta.type === "text_delta") { - streamed.textIds.add(streamed.currentStreamMessageId); - } else if (message.event.delta.type === "thinking_delta") { - streamed.thinkingIds.add(streamed.currentStreamMessageId); + const delta = message.event.delta; + const chunk = + delta.type === "text_delta" + ? { type: "text" as const, text: delta.text } + : delta.type === "thinking_delta" + ? { type: "thinking" as const, text: delta.thinking } + : undefined; + // An empty entry would stall the diff cursor in the assistant handler. + if (chunk && chunk.text.length > 0) { + const index = message.event.index; + const last = streamed.blocks[streamed.blocks.length - 1]; + if (last && last.index === index && last.type === chunk.type) { + last.text += chunk.text; + } else { + streamed.blocks.push({ index, type: chunk.type, text: chunk.text }); + } } } } @@ -986,6 +1032,7 @@ export async function handleStreamEvent( context.session.cwd, context.enrichedReadCache, context.session.taskState, + context.emittedToolCalls, )) { await client.sessionUpdate(notification); context.session.notificationHistory.push(notification); @@ -1122,14 +1169,9 @@ function logSpecialMessages( } } -/** - * Drops assistant `text`/`thinking` blocks that already reached the client as - * streamed chunks, while forwarding (as a fallback) any non-empty block that - * never streamed — see `StreamedAssistantBlocks`. Subagent assistant content - * (`parent_tool_use_id !== null`) is never streamed and stays internal to its - * tool call, so it is always dropped, as is everything when no tracker is - * available (replay). - */ +// Forwards only the un-streamed remainder of each assistant text/thinking +// block: nothing, the whole block (non-streaming gateway) or a cut-short +// tail. Subagent content and tracker-less replay stay dropped. function filterAssistantContent( message: SDKAssistantMessage, streamed: StreamedAssistantBlocks | undefined, @@ -1152,22 +1194,46 @@ function filterAssistantContent( ); } - const id = message.message.id || undefined; - return content.filter((block) => { + // streamPos walks the streamed record in step with the assembled + // text/thinking blocks; other block types pass through without advancing. + const kept: typeof content = []; + let streamPos = 0; + for (const block of content) { if (block.type !== "text" && block.type !== "thinking") { - return true; + kept.push(block); + continue; } - const streamedLive = - id !== undefined && - (block.type === "text" ? streamed.textIds : streamed.thinkingIds).has(id); - if (streamedLive) { - return false; + const full = block.type === "text" ? block.text : block.thinking; + if (full.length === 0) { + continue; } - // Some gateways emit an empty `thinking` block before the real text — - // don't forward stray empty chunks. - const blockText = block.type === "text" ? block.text : block.thinking; - return blockText.length > 0; - }); + // A same-type streamed prefix means the block (or its head) was already + // delivered as chunks; consume it and forward only what's left. + const streamedBlock = streamed.blocks[streamPos]; + if ( + streamedBlock && + streamedBlock.type === block.type && + streamedBlock.text.length > 0 && + full.startsWith(streamedBlock.text) + ) { + streamPos++; + const remainder = full.slice(streamedBlock.text.length); + if (remainder.length === 0) { + continue; + } + // Overwrite in place so the block keeps its exact SDK type. + if (block.type === "text") { + block.text = remainder; + } else { + block.thinking = remainder; + } + kept.push(block); + continue; + } + kept.push(block); + } + streamed.blocks.length = 0; + return kept; } export async function handleUserAssistantMessage( @@ -1273,6 +1339,7 @@ export async function handleUserAssistantMessage( mcpToolUseResult, context.enrichedReadCache, session.taskState, + context.emittedToolCalls, )) { // The renderer ignores raw user chunks; mark imported ones so the load path can promote them. if ( diff --git a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts index 0587403e2e..7cef453984 100644 --- a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts @@ -558,7 +558,9 @@ export function toolUpdateFromToolResult( "is_error" in toolResult && toolResult.is_error && toolResult.content && - (toolResult.content as unknown[]).length > 0 + (toolResult.content as unknown[]).length > 0 && + // Bash errors keep rendering through the terminal-output channel below. + !(toolUse?.name === "Bash" && options?.supportsTerminalOutput) ) { return toAcpContentUpdate(toolResult.content, true); } @@ -650,13 +652,22 @@ export function toolUpdateFromToolResult( exitCode = bashResult.return_code; } else if (typeof result === "string") { output = result; - } else if ( - Array.isArray(result) && - result.length > 0 && - "text" in result[0] && - typeof result[0].text === "string" - ) { - output = result.map((c: { text?: string }) => c.text ?? "").join("\n"); + } else if (Array.isArray(result) && result.length > 0) { + const textOnly = result.every( + (c) => + c && + typeof c === "object" && + typeof (c as { text?: unknown }).text === "string", + ); + if (textOnly) { + output = result + .map((c: { text?: string }) => c.text ?? "") + .join("\n"); + } else { + // Binary payloads can't ride the terminal-output _meta channel; + // surface image/mixed content as ACP content blocks instead. + return toAcpContentUpdate(result, isError === true); + } } if (options?.supportsTerminalOutput) { diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts index 528a219e18..0956c10037 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts @@ -5,6 +5,18 @@ import { } from "../mcp/tool-metadata"; import { canUseTool } from "./permission-handlers"; +function createClient(response: Record) { + const requestPermission = vi.fn().mockResolvedValue(response); + return { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + requestPermission, + // Delegate so assertions keep one target. + request: vi.fn((_method: string, params: unknown) => + requestPermission(params), + ), + }; +} + function createContext( toolName: string, overrides: Record = {}, @@ -22,12 +34,9 @@ function createContext( toolUseID: "test-tool-use-id", suggestions: undefined, signal: undefined, - client: { - sessionUpdate: vi.fn().mockResolvedValue(undefined), - requestPermission: vi.fn().mockResolvedValue({ - outcome: { outcome: "selected", optionId: "allow" }, - }), - }, + client: createClient({ + outcome: { outcome: "selected", optionId: "allow" }, + }), sessionId: "test-session", fileContentCache: {}, logger: { @@ -252,12 +261,9 @@ describe("canUseTool MCP approval enforcement", () => { addPostHogExecApproval: addApproval, }, }, - client: { - sessionUpdate: vi.fn().mockResolvedValue(undefined), - requestPermission: vi.fn().mockResolvedValue({ - outcome: { outcome: "selected", optionId: "allow_always" }, - }), - }, + client: createClient({ + outcome: { outcome: "selected", optionId: "allow_always" }, + }), }); const result = await canUseTool(context); @@ -287,12 +293,9 @@ describe("canUseTool MCP approval enforcement", () => { addPostHogExecApproval: addApproval, }, }, - client: { - sessionUpdate: vi.fn().mockResolvedValue(undefined), - requestPermission: vi.fn().mockResolvedValue({ - outcome: { outcome: "selected", optionId: "allow" }, - }), - }, + client: createClient({ + outcome: { outcome: "selected", optionId: "allow" }, + }), }); const result = await canUseTool(context); diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 485c266c80..62dfea64c2 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -1,6 +1,8 @@ -import type { - AgentSideConnection, - RequestPermissionResponse, +import { + type AgentSideConnection, + methods, + type RequestPermissionRequest, + type RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import type { PermissionRuleValue, @@ -63,6 +65,81 @@ interface ToolHandlerContext { updateConfigOption: (configId: string, value: string) => Promise; applySessionMode: (modeId: string) => Promise; allowedDomains?: string[]; + /** Shared with the streamed tool_use path; first emitter wins. */ + emittedToolCalls?: Set; + supportsTerminalOutput?: boolean; +} + +// Task*/TodoWrite render as plans, never as standalone tool_calls. +function shouldEmitToolCall(toolName: string): boolean { + return ( + toolName !== "TodoWrite" && + toolName !== "TaskCreate" && + toolName !== "TaskUpdate" && + toolName !== "TaskList" && + toolName !== "TaskGet" + ); +} + +// The SDK can invoke canUseTool before the tool_use block streams; make +// sure the tool_call exists before the client is asked to approve it. +async function ensureToolCallEmitted( + context: ToolHandlerContext, +): Promise { + const { emittedToolCalls, toolName, toolUseID, toolInput } = context; + if (!emittedToolCalls || !shouldEmitToolCall(toolName)) { + return; + } + if (emittedToolCalls.has(toolUseID)) { + return; + } + emittedToolCalls.add(toolUseID); + const toolInfo = toolInfoFromToolUse( + { name: toolName, input: toolInput }, + { + supportsTerminalOutput: context.supportsTerminalOutput, + toolUseId: toolUseID, + cachedFileContent: context.fileContentCache, + cwd: context.session.cwd, + }, + ); + await context.client.sessionUpdate({ + sessionId: context.sessionId, + update: { + _meta: { + claudeCode: { toolName }, + ...(toolName === "Bash" && context.supportsTerminalOutput + ? { terminal_info: { terminal_id: toolUseID } } + : {}), + }, + toolCallId: toolUseID, + sessionUpdate: "tool_call", + rawInput: toolInput, + status: "pending", + ...toolInfo, + }, + }); +} + +// The cancellationSignal lets a turn cancel dismiss the client's open +// dialog ($/cancel_request) instead of leaving this await hanging. +async function requestPermissionFromClient( + context: ToolHandlerContext, + params: RequestPermissionRequest, +): Promise { + await ensureToolCallEmitted(context); + try { + return await context.client.request( + methods.client.session.requestPermission, + params, + { cancellationSignal: context.signal }, + ); + } catch (error) { + if (context.signal?.aborted) { + throw new Error("Tool use aborted", { cause: error }); + } + throw error; + } } async function emitToolDenial( @@ -159,14 +236,14 @@ async function requestPlanApproval( context: ToolHandlerContext, updatedInput: Record, ): Promise { - const { client, sessionId, toolUseID, session } = context; + const { sessionId, toolUseID, session } = context; const toolInfo = toolInfoFromToolUse({ name: context.toolName, input: updatedInput, }); - return await client.requestPermission({ + return await requestPermissionFromClient(context, { options: buildExitPlanModePermissionOptions(session.modeBeforePlan), sessionId, toolCall: { @@ -281,7 +358,7 @@ async function handleAskUserQuestionTool( }; } - const { client, sessionId, toolUseID, toolInput } = context; + const { sessionId, toolUseID, toolInput } = context; const firstQuestion = questions[0]; const options = buildQuestionOptions(firstQuestion); @@ -290,7 +367,7 @@ async function handleAskUserQuestionTool( input: toolInput, }); - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options, sessionId, toolCall: { @@ -342,15 +419,8 @@ async function handleAskUserQuestionTool( async function handleDefaultPermissionFlow( context: ToolHandlerContext, ): Promise { - const { - session, - toolName, - toolInput, - toolUseID, - client, - sessionId, - suggestions, - } = context; + const { session, toolName, toolInput, toolUseID, sessionId, suggestions } = + context; const toolInfo = toolInfoFromToolUse( { name: toolName, input: toolInput }, @@ -370,7 +440,7 @@ async function handleDefaultPermissionFlow( // and just shows the bare tool name (e.g. "exec") with no context. const isMcpTool = toolName.startsWith("mcp__"); - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options, sessionId, toolCall: { @@ -432,7 +502,7 @@ function parseMcpToolName(toolName: string): { async function handleMcpApprovalFlow( context: ToolHandlerContext, ): Promise { - const { toolName, toolInput, toolUseID, client, sessionId } = context; + const { toolName, toolInput, toolUseID, sessionId } = context; const { serverName, tool: displayTool } = parseMcpToolName(toolName); const metadata = getMcpToolMetadata(toolName); @@ -440,7 +510,7 @@ async function handleMcpApprovalFlow( ? `\n\n${metadata.description}` : ""; - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options: [ { kind: "allow_once", name: "Yes", optionId: "allow" }, { @@ -503,10 +573,9 @@ async function handlePostHogExecApprovalFlow( context: ToolHandlerContext, subTool: string, ): Promise { - const { toolName, toolInput, toolUseID, client, sessionId, session } = - context; + const { toolName, toolInput, toolUseID, sessionId, session } = context; - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options: [ { kind: "allow_once", name: "Yes", optionId: "allow" }, { diff --git a/packages/agent/src/adapters/claude/session/models.test.ts b/packages/agent/src/adapters/claude/session/models.test.ts index b56c9001cf..c4797ff76a 100644 --- a/packages/agent/src/adapters/claude/session/models.test.ts +++ b/packages/agent/src/adapters/claude/session/models.test.ts @@ -223,4 +223,32 @@ describe("resolveModelPreference", () => { expect(resolveModelPreference("best", options)).toBeNull(); expect(resolveModelPreference("default", options)).toBeNull(); }); + + it.each([ + { + label: "resolves single-number family versions like Sonnet 5", + preference: "sonnet 5", + models: [ + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + expected: "claude-sonnet-5", + }, + { + label: "refuses a cross-version match between bare and dotted versions", + preference: "claude-sonnet-4-6", + models: [{ value: "sonnet", name: "Claude Sonnet 5" }], + expected: null, + }, + { + // "sonnet[1m]" carries no version; a versioned preference must still + // match it rather than being rejected on the hint's "1". + label: "does not read a [1m] context hint as a family version", + preference: "claude-sonnet-4-6", + models: [{ value: "sonnet[1m]", name: "Claude Sonnet (1M)" }], + expected: "sonnet[1m]", + }, + ])("$label", ({ preference, models, expected }) => { + expect(resolveModelPreference(preference, models)).toBe(expected); + }); }); diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 5fad1e729b..71cee5d441 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -66,6 +66,17 @@ export function supportsMcpInjection(modelId: string): boolean { return !MODELS_TO_EXCLUDE_MCP_TOOLS.has(modelId); } +const MODELS_WITH_FAST_MODE = new Set(["claude-opus-4-7", "claude-opus-4-8"]); + +export function supportsFastMode(modelId: string): boolean { + return MODELS_WITH_FAST_MODE.has(modelId); +} + +// cooldown keeps the toggle on (user intent); only an explicit off clears it. +export function fastModeStateEnabled(state: string | undefined): boolean { + return state !== undefined && state !== "off"; +} + interface EffortOption { value: string; name: string; @@ -124,15 +135,18 @@ interface ModelOption { description?: string; } -// Captures a model family version such as `4-6` or `4.7` so we can keep -// `claude-opus-4-7` from being copied onto the SDK's `opus` alias when that -// alias currently resolves to a different family version (e.g. Opus 4.8). -const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/; +// Captures a model family version: `4-6`/`4.7` for dated generations, or a +// bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned +// `claude-opus-4-7` from matching the `opus` alias once it points at 4.8. +const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/; function extractModelFamilyVersion(s: string | undefined): string | null { if (!s) return null; - const match = s.match(MODEL_FAMILY_VERSION_PATTERN); - return match ? `${match[1]}.${match[2]}` : null; + // Strip "[1m]"-style context hints first — that digit is context window + // size, not a model generation version. + const match = s.replace(/\[\d+m\]/gi, "").match(MODEL_FAMILY_VERSION_PATTERN); + if (!match) return null; + return match[2] ? `${match[1]}.${match[2]}` : match[1]; } function modelVersionsCompatible( diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3c1973125a..3d423746d1 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -1,4 +1,5 @@ import type { + PromptResponse, SessionConfigOption, TerminalHandle, TerminalOutputResponse, @@ -38,9 +39,16 @@ export type BackgroundTerminal = pendingOutput: TerminalOutputResponse; }; -export type PendingMessage = { - resolve: (cancelled: boolean) => void; - order: number; +/** One in-flight `prompt()` call, settled by the session's consumer. */ +export type Turn = { + promptUuid: string; + isLocalOnlyCommand: boolean; + commandName?: string; + /** Invoked once at activation, matching the pre-consumer broadcast timing. */ + broadcast: () => Promise; + settled: boolean; + resolve: (response: PromptResponse) => void; + reject: (error: unknown) => void; }; export type Session = BaseSession & { @@ -66,6 +74,10 @@ export type Session = BaseSession & { lastPlanFilePath?: string; lastPlanContent?: string; effort?: EffortLevel; + /** User intent; retained while a non-fast model hides the "fast" option. */ + fastModeEnabled: boolean; + /** Last title pushed via `session_info_update`, to dedupe turn-end polls. */ + lastTitle?: string; configOptions: SessionConfigOption[]; accumulatedUsage: AccumulatedUsage; /** PostHog products used during this session, derived from MCP exec calls. @@ -79,11 +91,18 @@ export type Session = BaseSession & { contextSize?: number; /** Persists across prompt() calls so SDK-reported values survive turn boundaries */ lastContextWindowSize?: number; - promptRunning: boolean; + /** FIFO of in-flight prompts; the SDK echoes them back in order. */ + turnQueue: Turn[]; + activeTurn: Turn | null; + /** Echo-less results still owed by turns cancelled while queued. */ + pendingOrphanResults: number; + consumer?: Promise; + /** Bumped by refreshSession so the retired consumer exits quietly. */ + queryGeneration: number; + /** The query iterator ended and can't be revived; new prompts reject. */ + queryClosed?: boolean; cancelController?: AbortController; forceCancelTimer?: ReturnType; - pendingMessages: Map; - nextPendingOrder: number; emitRawSDKMessages: boolean | SDKMessageFilter[]; /** Refreshed at session init and on MCP/skill changes. */ contextBreakdownBaseline?: ContextBreakdownBaseline; diff --git a/packages/agent/src/test/mocks/claude-sdk.ts b/packages/agent/src/test/mocks/claude-sdk.ts index 4bb084931d..3534cfc12a 100644 --- a/packages/agent/src/test/mocks/claude-sdk.ts +++ b/packages/agent/src/test/mocks/claude-sdk.ts @@ -108,6 +108,8 @@ export function createMockQuery( .mockResolvedValue({}), reloadPlugins: vi.fn().mockResolvedValue(undefined), reloadSkills: vi.fn().mockResolvedValue(undefined), + setMcpPermissionModeOverride: vi.fn().mockResolvedValue({}), + reinitialize: vi.fn().mockResolvedValue({}), seedReadState: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), backgroundTasks: vi.fn().mockResolvedValue([]), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cf4edaa57..c555bb33ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -686,14 +686,14 @@ importers: packages/agent: dependencies: '@agentclientprotocol/sdk': - specifier: 0.25.0 - version: 0.25.0(zod@4.4.3) + specifier: 1.1.0 + version: 1.1.0(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': - specifier: 0.3.170 - version: 0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: 0.3.197 + version: 0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': - specifier: 0.104.1 - version: 0.104.1(zod@4.4.3) + specifier: 0.109.0 + version: 0.109.0(zod@4.4.3) '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) @@ -1433,7 +1433,7 @@ importers: version: 0.22.1(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': specifier: 0.3.156 - version: 0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + version: 0.3.156(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@hono/node-server': specifier: 'catalog:' version: 1.19.9(hono@4.11.7) @@ -1551,8 +1551,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@agentclientprotocol/sdk@0.25.0': - resolution: {integrity: sha512-wU1VgXNtMvdVotX49txc3WJUDV+/QbLpsgjMvFhlRmp37osdLbI7L7y+iwAlQATwfjLxcv1r1p3ZxZBcXlGhcQ==} + '@agentclientprotocol/sdk@1.1.0': + resolution: {integrity: sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1565,8 +1565,8 @@ packages: cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': - resolution: {integrity: sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': + resolution: {integrity: sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw==} cpu: [arm64] os: [darwin] @@ -1575,8 +1575,8 @@ packages: cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170': - resolution: {integrity: sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': + resolution: {integrity: sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA==} cpu: [x64] os: [darwin] @@ -1586,8 +1586,8 @@ packages: os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170': - resolution: {integrity: sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': + resolution: {integrity: sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg==} cpu: [arm64] os: [linux] libc: [musl] @@ -1598,8 +1598,8 @@ packages: os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': - resolution: {integrity: sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': + resolution: {integrity: sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg==} cpu: [arm64] os: [linux] libc: [glibc] @@ -1610,8 +1610,8 @@ packages: os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': - resolution: {integrity: sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': + resolution: {integrity: sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w==} cpu: [x64] os: [linux] libc: [musl] @@ -1622,8 +1622,8 @@ packages: os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': - resolution: {integrity: sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': + resolution: {integrity: sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw==} cpu: [x64] os: [linux] libc: [glibc] @@ -1633,8 +1633,8 @@ packages: cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': - resolution: {integrity: sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': + resolution: {integrity: sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w==} cpu: [arm64] os: [win32] @@ -1643,8 +1643,8 @@ packages: cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170': - resolution: {integrity: sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': + resolution: {integrity: sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q==} cpu: [x64] os: [win32] @@ -1656,16 +1656,16 @@ packages: '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/claude-agent-sdk@0.3.170': - resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} + '@anthropic-ai/claude-agent-sdk@0.3.197': + resolution: {integrity: sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.104.1': - resolution: {integrity: sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==} + '@anthropic-ai/sdk@0.109.0': + resolution: {integrity: sha512-y7P4eLyW5uNut4fXpOUEHqhJwx7dnxrWAfCQE4Lcgm0hSFQuIeHa7CWEKE5dFolEjQJE/RFKkppjri05r2OK/Q==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -13508,7 +13508,7 @@ snapshots: dependencies: zod: 4.4.3 - '@agentclientprotocol/sdk@0.25.0(zod@4.4.3)': + '@agentclientprotocol/sdk@1.1.0(zod@4.4.3)': dependencies: zod: 4.4.3 @@ -13517,54 +13517,54 @@ snapshots: '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.104.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.109.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: @@ -13577,22 +13577,22 @@ snapshots: '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156 '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156 - '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.104.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.109.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.170 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.170 - - '@anthropic-ai/sdk@0.104.1(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.197 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.197 + + '@anthropic-ai/sdk@0.109.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0