@agentclientprotocol/claude-agent-acp 0.45.1 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,10 +31,54 @@ type SessionModelState = {
31
31
  }>;
32
32
  currentModelId: string;
33
33
  };
34
+ /** One in-flight `prompt()` call. A persistent per-session consumer (see
35
+ * `runConsumer`) drains the SDK query stream for the whole session and settles
36
+ * each Turn's deferred when that turn's outcome is known, so `prompt()` itself
37
+ * holds no loop. Turns are processed FIFO: the SDK echoes queued user messages
38
+ * back in submission order, so `turnQueue[0]` is the turn currently running. */
39
+ type Turn = {
40
+ /** uuid stamped on the pushed `SDKUserMessage`; the SDK echoes it back so the
41
+ * consumer can match the replayed user message to this turn. */
42
+ promptUuid: string;
43
+ /** Local-only slash commands (e.g. `/clear`) return a result without an echo,
44
+ * so the consumer can't promote them via the replay; it falls back to
45
+ * promoting the queue head when the result arrives. */
46
+ isLocalOnlyCommand: boolean;
47
+ /** Set once the deferred has been resolved/rejected, so the consumer never
48
+ * settles a turn twice (idle + handoff + stream-end can all race). */
49
+ settled: boolean;
50
+ resolve: (response: PromptResponse) => void;
51
+ reject: (error: unknown) => void;
52
+ };
34
53
  type Session = {
35
54
  query: Query;
36
55
  input: Pushable<SDKUserMessage>;
37
56
  cancelled: boolean;
57
+ /** FIFO of in-flight prompts. The head is the turn the SDK is currently
58
+ * processing; later entries are queued and will be echoed in order. */
59
+ turnQueue?: Turn[];
60
+ /** The turn whose messages the consumer is currently attributing output to
61
+ * (the head of `turnQueue` once its user message has been echoed). */
62
+ activeTurn?: Turn | null;
63
+ /** Count of result messages the consumer should treat as orphans and skip
64
+ * (not promote/attribute to the current head). When cancel() settles+removes
65
+ * a queued turn, that turn's user message was already pushed to the SDK, so
66
+ * the SDK still runs it and emits a result with no uuid we can match. Because
67
+ * the SDK processes input FIFO, those orphan results arrive (in submission
68
+ * order) before the next live turn's, so skipping exactly this many leaves
69
+ * the genuine head untouched. Reset to 0 on every activation as a backstop
70
+ * against an SDK that drops queued input on interrupt (no orphan emitted). */
71
+ pendingOrphanResults?: number;
72
+ /** The long-lived consumer task. Lazily started on the first `prompt()` and
73
+ * kept alive for the session so between-turn/background messages are still
74
+ * drained and forwarded. */
75
+ consumer?: Promise<void>;
76
+ /** Set once the SDK query stream has terminated (it ran to `done` or threw a
77
+ * non-process error). The query iterator is not reusable afterward, so a
78
+ * later `prompt()` rejects instead of enqueueing onto a dead stream and
79
+ * hanging (or silently restarting a consumer that resolves `end_turn`
80
+ * without ever reaching the model). */
81
+ queryClosed?: boolean;
38
82
  cwd: string;
39
83
  /** Serialized snapshot of session-defining params (cwd, mcpServers) used to
40
84
  * detect when loadSession/resumeSession is called with changed values. */
@@ -45,22 +89,16 @@ type Session = {
45
89
  models: SessionModelState;
46
90
  modelInfos: ModelInfo[];
47
91
  configOptions: SessionConfigOption[];
48
- promptRunning: boolean;
49
- pendingMessages: Map<string, {
50
- resolve: (cancelled: boolean) => void;
51
- order: number;
52
- }>;
53
- nextPendingOrder: number;
54
92
  abortController: AbortController;
55
- /** Per-turn signal the active prompt loop races `query.next()` against.
56
- * Aborted by cancel() (after a grace period) to force the loop to return
57
- * "cancelled" when the SDK is wedged and `query.next()` never yields again
58
- * (issue #680). Distinct from `abortController`: this only wakes the loop;
59
- * it does NOT touch the SDK query/subprocess. Undefined when no prompt is
60
- * actively consuming the query. */
93
+ /** Signal the consumer races `query.next()` against. Aborted by cancel()
94
+ * (after a grace period) to force the active turn to settle "cancelled" when
95
+ * the SDK is wedged and `query.next()` never yields again (issue #680).
96
+ * Distinct from `abortController`: this only wakes the consumer; it does NOT
97
+ * touch the SDK query/subprocess. The consumer re-arms it after each fire.
98
+ * Undefined until the consumer is started by the first prompt. */
61
99
  cancelController?: AbortController;
62
- /** Pending grace-period timer that aborts `cancelController`. Cleared when
63
- * the loop returns normally so the backstop never fires after a clean
100
+ /** Pending grace-period timer that aborts `cancelController`. Cleared when the
101
+ * active turn settles normally so the backstop never fires after a clean
64
102
  * cancel. */
65
103
  forceCancelTimer?: ReturnType<typeof setTimeout>;
66
104
  emitRawSDKMessages: boolean | SDKMessageFilter[];
@@ -220,9 +258,38 @@ export declare class ClaudeAcpAgent implements Agent {
220
258
  listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse>;
221
259
  authenticate(_params: AuthenticateRequest): Promise<void>;
222
260
  prompt(params: PromptRequest): Promise<PromptResponse>;
261
+ /** Lazily start the per-session consumer that drains the SDK query stream for
262
+ * the session's whole life. Idempotent: only the first `prompt()` starts it. */
263
+ private ensureConsumer;
264
+ /** The single, long-lived consumer of the SDK query stream for a session. It
265
+ * forwards every message as ACP `sessionUpdate`s (so background/between-turn
266
+ * output streams live, not just while a prompt is awaiting) and settles each
267
+ * Turn's deferred when that turn ends. Replaces the per-prompt message loop;
268
+ * `params` only carries the (session-invariant) `sessionId`. */
269
+ private runConsumer;
223
270
  cancel(params: CancelNotification): Promise<void>;
224
- /** Cleanly tear down a session: cancel in-flight work, dispose resources,
225
- * and remove it from the session map. */
271
+ /** Mark a session's SDK query stream as permanently ended and release the
272
+ * resources tied to it: drop the consumer handle, dispose the settings
273
+ * watchers, end the input stream, and close the query (which terminates the
274
+ * subprocess). The query iterator is not revivable, so `prompt()`/`cancel()`
275
+ * consult `queryClosed` and fail/short-circuit instead of acting on a dead
276
+ * stream. Idempotent (guarded by `queryClosed`), so the consumer's done/error
277
+ * paths and a later `teardownSession` can all call it without double-releasing.
278
+ *
279
+ * Deliberately does NOT abort `session.abortController`: that controller may be
280
+ * CLIENT-supplied (`_meta.claudeCode.options.abortController`) and reused, so
281
+ * aborting it on a spontaneous stream end would cancel the client's own work
282
+ * or make a sibling session born aborted. `query.close()` already terminates
283
+ * the subprocess; aborting the signal belongs in `teardownSession` (explicit
284
+ * destroy), not here. Also does NOT remove the session from the map — that is
285
+ * `teardownSession`'s job — so prompt() can still answer with a clear "session
286
+ * ended" error after an unexpected stream close. The leftover session object
287
+ * is a lightweight husk (its heavy resources are released here) and is evicted
288
+ * on the next closeSession/deleteSession or when the connection's `dispose()`
289
+ * runs. */
290
+ private closeQueryStream;
291
+ /** Cleanly tear down a session: cancel in-flight work, release stream
292
+ * resources, and remove it from the session map. */
226
293
  private teardownSession;
227
294
  /** Tear down all active sessions. Called when the ACP connection closes. */
228
295
  dispose(): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"acp-agent.d.ts","sourceRoot":"","sources":["../src/acp-agent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"acp-agent.d.ts","sourceRoot":"","sources":["../src/acp-agent.ts"],"names":[],"mappings":"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"}
package/dist/acp-agent.js CHANGED
@@ -93,6 +93,10 @@ function isMuslLibc() {
93
93
  function shouldHideClaudeAuth() {
94
94
  return process.argv.includes("--hide-claude-auth");
95
95
  }
96
+ /** Returned to clients when a prompt or cancel targets a session whose SDK
97
+ * query stream has already ended (ran to `done` or died). The stream is not
98
+ * revivable, so the only recovery is a fresh session. */
99
+ const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
96
100
  // Bypass Permissions doesn't work if we are a root/sudo user
97
101
  const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
98
102
  const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
@@ -460,112 +464,273 @@ export class ClaudeAcpAgent {
460
464
  if (!session) {
461
465
  throw new Error("Session not found");
462
466
  }
463
- session.cancelled = false;
464
- session.accumulatedUsage = {
465
- inputTokens: 0,
466
- outputTokens: 0,
467
- cachedReadTokens: 0,
468
- cachedWriteTokens: 0,
467
+ // The SDK query stream already terminated (see `queryClosed`); its iterator
468
+ // can't be revived, so enqueueing here would hang on a deferred that never
469
+ // settles. Fail clearly and let the client start a fresh session.
470
+ if (session.queryClosed) {
471
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
472
+ }
473
+ const userMessage = promptToClaude(params);
474
+ const promptUuid = randomUUID();
475
+ userMessage.uuid = promptUuid;
476
+ // Local-only commands (e.g. `/clear`) return a result without replaying the
477
+ // user message, so the consumer can't promote the turn from the echo.
478
+ const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
479
+ const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
480
+ // Each prompt is a Turn whose deferred the persistent consumer settles once
481
+ // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
482
+ // turn, pushes the user message onto the streaming input, makes sure the
483
+ // consumer is running, and awaits the deferred.
484
+ const turn = {
485
+ promptUuid,
486
+ isLocalOnlyCommand,
487
+ settled: false,
488
+ resolve: () => { },
489
+ reject: () => { },
469
490
  };
491
+ const response = new Promise((resolve, reject) => {
492
+ turn.resolve = resolve;
493
+ turn.reject = reject;
494
+ });
495
+ session.turnQueue ??= [];
496
+ session.turnQueue.push(turn);
497
+ session.input.push(userMessage);
498
+ this.ensureConsumer(session, params.sessionId);
499
+ return response;
500
+ }
501
+ /** Lazily start the per-session consumer that drains the SDK query stream for
502
+ * the session's whole life. Idempotent: only the first `prompt()` starts it. */
503
+ ensureConsumer(session, sessionId) {
504
+ if (session.consumer) {
505
+ return;
506
+ }
507
+ // Wake-up channel so cancel() can force the consumer to settle the active
508
+ // turn "cancelled" even when query.next() is wedged and never yields again
509
+ // (issue #680). The consumer re-arms it after each fire.
510
+ session.cancelController = new AbortController();
511
+ session.consumer = this.runConsumer(session, { sessionId });
512
+ session.consumer.catch((error) => {
513
+ this.logger.error(`Session ${sessionId}: consumer terminated unexpectedly: ${error}`);
514
+ });
515
+ }
516
+ /** The single, long-lived consumer of the SDK query stream for a session. It
517
+ * forwards every message as ACP `sessionUpdate`s (so background/between-turn
518
+ * output streams live, not just while a prompt is awaiting) and settles each
519
+ * Turn's deferred when that turn ends. Replaces the per-prompt message loop;
520
+ * `params` only carries the (session-invariant) `sessionId`. */
521
+ async runConsumer(session, params) {
522
+ // Per-turn scratch, reset whenever a turn becomes active. Kept as consumer
523
+ // locals (rather than per-Turn fields) because they describe the message
524
+ // currently being processed, which is sequential — exactly one turn is
525
+ // active at a time. Mirrors the locals the old per-prompt loop held.
470
526
  let lastAssistantTotalUsage = null;
471
527
  let lastAssistantUsage = null;
472
528
  let lastAssistantModel = null;
473
529
  // When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
474
530
  // problem, billing), it sets a categorical `error` field on the
475
- // `SDKAssistantMessage` that precedes the final `result` message. We
476
- // capture it here so the subsequent `RequestError.internalError` can
477
- // forward it to clients as structured `data`, sparing them from
478
- // pattern-matching on the human-readable message text.
531
+ // `SDKAssistantMessage` that precedes the final `result` message. We capture
532
+ // it here so the subsequent `RequestError.internalError` can forward it to
533
+ // clients as structured `data`, sparing them from pattern-matching on text.
479
534
  let lastAssistantError;
480
535
  // When a streaming classifier refuses a turn, the assistant message carries
481
536
  // stop_reason "refusal" and structured stop_details. We capture the
482
- // human-readable explanation here so the terminal `result` can surface it
483
- // to the user (the refused assistant message itself usually has no content)
484
- // and report ACP's dedicated `refusal` stop reason.
537
+ // human-readable explanation so the terminal `result` can surface it.
485
538
  let lastRefusalExplanation = null;
486
539
  // Tracks whether we're inside a compaction. The SDK emits the terminal
487
540
  // `status` (compact_result success/failed) twice for a single failed
488
541
  // compaction, and the two messages are indistinguishable — so we report the
489
- // outcome only while a compaction is in progress, then clear this. A fresh
490
- // `compacting` status sets it again, so every distinct compaction (e.g.
491
- // repeated auto-compactions in a long turn) is still shown.
542
+ // outcome only while a compaction is in progress, then clear this.
492
543
  let compactionInProgress = false;
493
- // Holds the Anthropic API message id of the assistant message currently
494
- // being streamed, captured from `message_start` so every streamed chunk can
495
- // be tagged with it. We use the API message id rather than the
496
- // per-`stream_event` uuid because the same id is also present on the
497
- // consolidated assistant message and in the persisted transcript — so a turn
498
- // keeps the same ACP `messageId` whether it is streamed live or replayed
499
- // from history. The per-event uuid is unique per event and never persisted.
500
- // A single value suffices because every streaming partial arrives with
501
- // `parent_tool_use_id === null` (subagent work is folded into tool-result
502
- // messages, never surfaced as partial streams).
544
+ // Anthropic API message id of the assistant message currently being
545
+ // streamed, captured from `message_start` so the streamed chunks that follow
546
+ // (whose delta events don't carry it) can all be tagged with the same,
547
+ // replay-stable id.
503
548
  let currentStreamMessageId;
504
- // Per-message-id record of which assistant content actually streamed live via
505
- // `stream_event` deltas, split by block type. The `assistant` case below
506
- // normally drops `text`/`thinking` blocks on the assumption they already
507
- // reached the client as `agent_message_chunk`/`agent_thought_chunk`. That
508
- // breaks behind Anthropic-protocol gateways that return a turn as a single
509
- // non-streamed block (common with OpenAI-compatible proxies): no
510
- // `content_block_delta` fires, so the assembled block is the only copy the
511
- // client will ever see. We keep the filter per (id, block type) for content
512
- // that did stream, and fall back to forwarding what did not. Split by type so
513
- // a gateway that streams text but not thinking (or vice versa) doesn't lose
514
- // the un-streamed block. Only top-level (`parent_tool_use_id === null`)
515
- // streams are recorded — subagent text is never streamed and must stay
516
- // filtered, as it is internal to the tool call.
517
- const streamedTextIds = new Set();
518
- const streamedThinkingIds = new Set();
519
- const userMessage = promptToClaude(params);
520
- const promptUuid = randomUUID();
521
- userMessage.uuid = promptUuid;
522
- // These local-only commands return a result without replaying the user
523
- // message. Mark promptReplayed=true so their result isn't consumed as a
524
- // background task result.
525
- const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
526
- const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
527
- if (session.promptRunning) {
528
- session.input.push(userMessage);
529
- const order = session.nextPendingOrder++;
530
- const cancelled = await new Promise((resolve) => {
531
- session.pendingMessages.set(promptUuid, { resolve, order });
532
- });
533
- if (cancelled) {
534
- return { stopReason: "cancelled" };
535
- }
536
- }
537
- else {
538
- session.input.push(userMessage);
539
- }
540
- session.promptRunning = true;
541
- let handedOff = false;
542
- let errored = false;
549
+ // Per-message-id record of which assistant content actually streamed live
550
+ // via `stream_event` deltas, split by block type, so the consolidated
551
+ // `assistant` message can drop the duplicate blocks that already reached the
552
+ // client as chunks while still forwarding any that a non-streaming gateway
553
+ // delivered only as an assembled block.
554
+ let streamedTextIds = new Set();
555
+ let streamedThinkingIds = new Set();
556
+ // Stop reason accumulated for the active turn (result subtype, refusal,
557
+ // max_tokens, …). Reset per turn; read when the turn settles at idle.
543
558
  let stopReason = "end_turn";
544
- // Wake-up channel so cancel() can force this loop to return "cancelled"
545
- // even when query.next() is wedged and never yields again (issue #680).
546
- const cancelController = new AbortController();
547
- session.cancelController = cancelController;
548
- const cancelled = new Promise((resolve) => {
549
- cancelController.signal.addEventListener("abort", () => resolve(), { once: true });
550
- });
559
+ const resetTurnScratch = () => {
560
+ lastAssistantTotalUsage = null;
561
+ lastAssistantUsage = null;
562
+ lastAssistantModel = null;
563
+ lastAssistantError = undefined;
564
+ lastRefusalExplanation = null;
565
+ compactionInProgress = false;
566
+ currentStreamMessageId = undefined;
567
+ streamedTextIds = new Set();
568
+ streamedThinkingIds = new Set();
569
+ stopReason = "end_turn";
570
+ session.accumulatedUsage = {
571
+ inputTokens: 0,
572
+ outputTokens: 0,
573
+ cachedReadTokens: 0,
574
+ cachedWriteTokens: 0,
575
+ };
576
+ };
577
+ /** Promote a queued turn to active: it becomes the one output is attributed
578
+ * to, and its scratch starts fresh. Clears the cancelled flag so a turn
579
+ * enqueued after a prior cancel isn't treated as cancelled. Also clears any
580
+ * leftover orphan-skip count: since the SDK echoes/runs input FIFO, every
581
+ * orphan from a prior cancel has already arrived by the time a live turn
582
+ * activates, so a non-zero remainder means the SDK dropped a queued turn on
583
+ * interrupt (no orphan emitted) — drop the stale count so a later echo-less
584
+ * result isn't wrongly skipped. */
585
+ const activateTurn = (turn) => {
586
+ session.activeTurn = turn;
587
+ session.cancelled = false;
588
+ session.pendingOrphanResults = 0;
589
+ resetTurnScratch();
590
+ };
591
+ /** Ensure there is an active turn before a user-turn result that carries no
592
+ * echo to activate it, by promoting the queue head. Most turns are
593
+ * activated by their replayed user message before their result, but some
594
+ * legitimately produce a result with no matching echo: local-only commands
595
+ * (e.g. `/context`) and compaction (`/compact`, whose only user messages
596
+ * are the generated summary and a `<local-command-stdout>` replay — neither
597
+ * carries the prompt's uuid). Promoting the head settles those.
598
+ *
599
+ * But an echo-less result can also be an ORPHAN: cancel() settles+removes a
600
+ * queued turn whose user message was already pushed, so the SDK still runs
601
+ * it and emits a result with no uuid to match. Promoting the head for an
602
+ * orphan would misattribute its stop reason/usage to an unrelated later
603
+ * prompt. `session.pendingOrphanResults` counts exactly how many such
604
+ * orphans are still expected (FIFO, they arrive before any live turn's
605
+ * result), so we skip those and only promote once the count is drained. */
606
+ const ensureActiveTurn = () => {
607
+ if (session.activeTurn) {
608
+ return;
609
+ }
610
+ const head = (session.turnQueue ?? []).find((t) => !t.settled);
611
+ if (!head) {
612
+ return;
613
+ }
614
+ if ((session.pendingOrphanResults ?? 0) > 0) {
615
+ session.pendingOrphanResults--;
616
+ return;
617
+ }
618
+ activateTurn(head);
619
+ };
620
+ /** Settle the active turn's deferred exactly once, disarm the force-cancel
621
+ * backstop (the turn is over), and drop it from the queue. */
622
+ const settleActive = (result) => {
623
+ const turn = session.activeTurn;
624
+ if (!turn || turn.settled) {
625
+ return;
626
+ }
627
+ turn.settled = true;
628
+ if (session.forceCancelTimer) {
629
+ clearTimeout(session.forceCancelTimer);
630
+ session.forceCancelTimer = undefined;
631
+ }
632
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
633
+ session.activeTurn = null;
634
+ turn.resolve(result);
635
+ };
636
+ /** Reject the active turn (auth required, error result, …) without tearing
637
+ * down the consumer: the stream continues to idle and later turns proceed. */
638
+ const failActive = (error) => {
639
+ if (session.forceCancelTimer) {
640
+ clearTimeout(session.forceCancelTimer);
641
+ session.forceCancelTimer = undefined;
642
+ }
643
+ const turn = session.activeTurn;
644
+ if (!turn || turn.settled) {
645
+ return;
646
+ }
647
+ turn.settled = true;
648
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
649
+ session.activeTurn = null;
650
+ turn.reject(error);
651
+ };
652
+ /** Reject every in-flight turn — used when the stream dies. */
653
+ const failAllTurns = (error) => {
654
+ if (session.forceCancelTimer) {
655
+ clearTimeout(session.forceCancelTimer);
656
+ session.forceCancelTimer = undefined;
657
+ }
658
+ const turns = session.activeTurn
659
+ ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
660
+ : [...(session.turnQueue ?? [])];
661
+ session.activeTurn = null;
662
+ session.turnQueue = [];
663
+ for (const turn of turns) {
664
+ if (!turn.settled) {
665
+ turn.settled = true;
666
+ turn.reject(error);
667
+ }
668
+ }
669
+ };
670
+ // The wake-up channel cancel()/teardown aborts to force the active turn to
671
+ // settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
672
+ // after each fire so the consumer keeps serving later turns.
673
+ let cancelController = session.cancelController;
551
674
  try {
552
675
  while (true) {
553
676
  const nextMessage = session.query.next();
554
- const next = await Promise.race([nextMessage, cancelled]);
555
- if (cancelController.signal.aborted) {
556
- // The SDK never yielded after interrupt() (e.g. a wedged TaskOutput
557
- // block). Abandon the in-flight next() — swallowing any later
558
- // rejection so it can't surface as an unhandled rejection — and
559
- // honor the cancel per the ACP contract.
677
+ // Fresh abort listener per iteration, removed when next() wins, so a
678
+ // long-lived session doesn't accumulate listeners on one signal.
679
+ let onAbort;
680
+ const abortRace = new Promise((resolve) => {
681
+ onAbort = () => resolve("abort");
682
+ cancelController.signal.addEventListener("abort", onAbort, { once: true });
683
+ });
684
+ const raced = await Promise.race([
685
+ nextMessage.then((result) => ({ kind: "message", result })),
686
+ abortRace,
687
+ ]);
688
+ cancelController.signal.removeEventListener("abort", onAbort);
689
+ if (raced === "abort") {
690
+ // cancel()/teardown woke us. Abandon the in-flight next() (swallowing
691
+ // any later rejection so it can't surface as unhandled) and settle the
692
+ // active turn "cancelled" per the ACP contract. If the session is
693
+ // being torn down, stop; otherwise re-arm and keep consuming.
560
694
  void nextMessage.catch(() => { });
561
- return { stopReason: "cancelled" };
695
+ settleActive({ stopReason: "cancelled" });
696
+ if (!this.sessions[params.sessionId]) {
697
+ return;
698
+ }
699
+ cancelController = new AbortController();
700
+ session.cancelController = cancelController;
701
+ continue;
562
702
  }
563
- const { value: message, done } = next;
703
+ const { value: message, done } = raced.result;
564
704
  if (done || !message) {
565
- if (session.cancelled) {
566
- return { stopReason: "cancelled" };
705
+ // The stream ended. Settle the in-flight turns FIRST, then release the
706
+ // stream resources — same order as the error paths (failAllTurns before
707
+ // closeQueryStream). Settling is the user-facing contract; resource
708
+ // release is best-effort cleanup, so a throw there must not pre-empt a
709
+ // turn's real outcome.
710
+ //
711
+ // Settle the turn that was in flight so its prompt() doesn't hang:
712
+ // cancelled if a cancel is pending, otherwise the accumulated outcome.
713
+ settleActive(session.cancelled
714
+ ? { stopReason: "cancelled" }
715
+ : { stopReason, usage: sessionUsage(session) });
716
+ // Queued turns the SDK never started never ran, so reject them rather
717
+ // than reporting a success (end_turn) — or a misleading "cancelled" —
718
+ // for a prompt that produced no output. (A cancel already settled the
719
+ // turns that were queued at cancel time and removed them, so anything
720
+ // still here was enqueued afterward and was not part of the cancel.)
721
+ for (const queued of [...(session.turnQueue ?? [])]) {
722
+ if (!queued.settled) {
723
+ queued.settled = true;
724
+ queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
725
+ }
567
726
  }
568
- break;
727
+ session.turnQueue = [];
728
+ // The query iterator can't be revived, so close the session's stream
729
+ // (marks queryClosed, drops the consumer handle, releases the dead
730
+ // subprocess/settings resources) — a later prompt() then rejects up
731
+ // front rather than restarting a consumer on the exhausted stream.
732
+ this.closeQueryStream(session);
733
+ return;
569
734
  }
570
735
  if (session.emitRawSDKMessages &&
571
736
  shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
@@ -663,10 +828,20 @@ export class ClaudeAcpAgent {
663
828
  }
664
829
  case "session_state_changed": {
665
830
  if (message.state === "idle") {
831
+ // A non-cancelled turn already settled at its terminal
832
+ // `result` (issue #773), so this trailing `idle` is just
833
+ // absorbed. We must NOT settle `activeTurn` here in that case:
834
+ // `idle` carries no turn identity, and it can lag (the SDK
835
+ // flushes held-back results / drains background agents first),
836
+ // so by the time it arrives the SDK may have echoed the NEXT
837
+ // turn and activated it — settling now would resolve that new
838
+ // turn prematurely with end_turn and ~zero usage, dropping its
839
+ // real result. Only a cancelled turn relies on `idle`: its
840
+ // `result` is dropped at the `session.cancelled` guard, so it
841
+ // never settles at a result and must settle here.
666
842
  if (session.cancelled) {
667
- stopReason = "cancelled";
843
+ settleActive({ stopReason: "cancelled" });
668
844
  }
669
- return { stopReason, usage: sessionUsage(session) };
670
845
  }
671
846
  break;
672
847
  }
@@ -764,6 +939,24 @@ export class ClaudeAcpAgent {
764
939
  });
765
940
  break;
766
941
  }
942
+ case "informational": {
943
+ // Free-form notice from the SDK (e.g. why a UserPromptSubmit/Stop
944
+ // hook blocked continuation). Surface the text so the user sees it
945
+ // instead of a silent stop. ACP's agent_message_chunk has no
946
+ // severity field, so fold the level into the text for the more
947
+ // prominent levels ('info' is transcript-only noise — leave plain).
948
+ const text = message.level === "info"
949
+ ? message.content
950
+ : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
951
+ await this.client.sessionUpdate({
952
+ sessionId: message.session_id,
953
+ update: {
954
+ sessionUpdate: "agent_message_chunk",
955
+ content: { type: "text", text },
956
+ },
957
+ });
958
+ break;
959
+ }
767
960
  case "hook_started":
768
961
  case "hook_progress":
769
962
  case "hook_response":
@@ -773,6 +966,11 @@ export class ClaudeAcpAgent {
773
966
  case "task_progress":
774
967
  case "task_updated":
775
968
  break;
969
+ case "worker_shutting_down":
970
+ // A Remote Control worker announced a graceful teardown. This is a
971
+ // live-tail signal for remote clients to explain why a session went
972
+ // away; it's not meaningful for a local stdio ACP session.
973
+ break;
776
974
  case "elicitation_complete": {
777
975
  // A url-mode MCP elicitation finished server-side. Let the client
778
976
  // dismiss any UI it opened for it. Only meaningful when the
@@ -802,11 +1000,32 @@ export class ClaudeAcpAgent {
802
1000
  }
803
1001
  break;
804
1002
  case "result": {
805
- // Accumulate usage from this result
806
- session.accumulatedUsage.inputTokens += message.usage.input_tokens;
807
- session.accumulatedUsage.outputTokens += message.usage.output_tokens;
808
- session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
809
- session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens;
1003
+ // Task-notification followups are autonomous work triggered by a
1004
+ // task-notification system message, not by the user's prompt.
1005
+ // They should not influence the user-turn lifecycle (stop reason,
1006
+ // slash-command output forwarding) but their cost is real.
1007
+ const isTaskNotification = message.origin?.kind === "task-notification";
1008
+ // A user-turn result needs an active turn so its stop reason is
1009
+ // attributed and the turn settles at idle. Local-only commands carry
1010
+ // no user-message echo to promote them, so do it here from the head.
1011
+ // Promote BEFORE accumulating usage, since activation resets the
1012
+ // accumulator — promoting after would discard this result's tokens.
1013
+ if (!isTaskNotification) {
1014
+ ensureActiveTurn();
1015
+ }
1016
+ // Accumulate usage into the user turn's tally. Skip task-notification
1017
+ // followups: their cost is real but is reported separately via the
1018
+ // usage_update below, and `session.accumulatedUsage` is only reset on
1019
+ // turn activation — so folding a task-notification result that lands
1020
+ // after the next turn is active (but before it settles) would leak
1021
+ // those tokens into that turn's PromptResponse.usage.
1022
+ if (!isTaskNotification) {
1023
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1024
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1025
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1026
+ session.accumulatedUsage.cachedWriteTokens +=
1027
+ message.usage.cache_creation_input_tokens;
1028
+ }
810
1029
  const matchingModelUsage = lastAssistantModel
811
1030
  ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
812
1031
  : null;
@@ -817,11 +1036,6 @@ export class ClaudeAcpAgent {
817
1036
  if (matchingModelUsage) {
818
1037
  session.contextWindowSize = matchingModelUsage.contextWindow;
819
1038
  }
820
- // Task-notification followups are autonomous work triggered by a
821
- // task-notification system message, not by the user's prompt.
822
- // They should not influence the user-turn lifecycle (stop reason,
823
- // slash-command output forwarding) but their cost is real.
824
- const isTaskNotification = message.origin?.kind === "task-notification";
825
1039
  // Send usage_update notification
826
1040
  if (lastAssistantTotalUsage !== null) {
827
1041
  await this.client.sessionUpdate({
@@ -863,12 +1077,14 @@ export class ClaudeAcpAgent {
863
1077
  });
864
1078
  }
865
1079
  stopReason = "refusal";
1080
+ settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
866
1081
  break;
867
1082
  }
868
1083
  switch (message.subtype) {
869
1084
  case "success": {
870
1085
  if (message.result.includes("Please run /login")) {
871
- throw RequestError.authRequired();
1086
+ failActive(RequestError.authRequired());
1087
+ break;
872
1088
  }
873
1089
  if (message.stop_reason === "max_tokens") {
874
1090
  if (!isTaskNotification) {
@@ -877,13 +1093,14 @@ export class ClaudeAcpAgent {
877
1093
  break;
878
1094
  }
879
1095
  if (message.is_error) {
880
- throw RequestError.internalError(errorKindData(lastAssistantError), message.result);
1096
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
1097
+ break;
881
1098
  }
882
1099
  // For local-only commands (no model invocation), the result
883
1100
  // text is the command output — forward it to the client.
884
1101
  // Task-notification followups never originate from a user
885
1102
  // slash command, so skip the forwarding for them.
886
- if (isLocalOnlyCommand && !isTaskNotification) {
1103
+ if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
887
1104
  for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
888
1105
  await this.client.sessionUpdate(notification);
889
1106
  }
@@ -898,7 +1115,8 @@ export class ClaudeAcpAgent {
898
1115
  break;
899
1116
  }
900
1117
  if (message.is_error) {
901
- throw RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype);
1118
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1119
+ break;
902
1120
  }
903
1121
  if (!isTaskNotification) {
904
1122
  stopReason = "end_turn";
@@ -909,7 +1127,8 @@ export class ClaudeAcpAgent {
909
1127
  case "error_max_turns":
910
1128
  case "error_max_structured_output_retries":
911
1129
  if (message.is_error) {
912
- throw RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype);
1130
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1131
+ break;
913
1132
  }
914
1133
  if (!isTaskNotification) {
915
1134
  stopReason = "max_turn_requests";
@@ -919,6 +1138,16 @@ export class ClaudeAcpAgent {
919
1138
  unreachable(message, this.logger);
920
1139
  break;
921
1140
  }
1141
+ // Settle the user turn at its terminal result so the client unlocks
1142
+ // as soon as the answer is done, rather than waiting for the SDK's
1143
+ // trailing `idle` (which can lag while background work runs — issue
1144
+ // #773). The consumer keeps draining afterward (absorbing idle and
1145
+ // forwarding any background output). is_error/auth already settled
1146
+ // via failActive; cancellation is left to the idle/abort path.
1147
+ // settleActive is idempotent, so a duplicate idle is a no-op.
1148
+ if (!isTaskNotification && !session.cancelled) {
1149
+ settleActive({ stopReason, usage: sessionUsage(session) });
1150
+ }
922
1151
  break;
923
1152
  }
924
1153
  case "stream_event": {
@@ -1002,36 +1231,49 @@ export class ClaudeAcpAgent {
1002
1231
  }
1003
1232
  case "user":
1004
1233
  case "assistant": {
1005
- if (session.cancelled) {
1006
- break;
1007
- }
1008
- // Record the ACP messageId -> SDK uuid mapping for this message. The
1009
- // consolidated message carries both ids, so this is where we learn
1010
- // the uuid that the SDK's rewind/resume APIs key on for the id we
1011
- // hand clients. Not read yet (see Session.messageIdToUuid).
1234
+ // Record the ACP messageId -> SDK uuid mapping for this message
1235
+ // (including replays). The consolidated message carries both ids, so
1236
+ // this is where we learn the uuid the SDK's rewind/resume APIs key on
1237
+ // for the id we hand clients. Not read yet (see messageIdToUuid).
1012
1238
  const mappedMessageId = messageIdForGrouping(message);
1013
1239
  if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
1014
1240
  session.messageIdToUuid.set(mappedMessageId, message.uuid);
1015
1241
  }
1016
- // Check for prompt replay
1242
+ // A replayed user message echoes a queued turn back in submission
1243
+ // order. The first echo promotes that turn to active; if a different
1244
+ // turn is still active, it is handed off (settled end_turn) first.
1245
+ // Done before the `cancelled` guard so a turn enqueued after a cancel
1246
+ // is still promoted — activateTurn() clears the flag. The turn's own
1247
+ // echo is then dropped from the feed (the client already shows it).
1017
1248
  if (message.type === "user" && "uuid" in message && message.uuid) {
1018
- if (message.uuid === promptUuid) {
1249
+ const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
1250
+ if (queued) {
1251
+ // Only (re)activate if this isn't already the active turn — a
1252
+ // turn promoted early (e.g. by a result that preceded its echo)
1253
+ // must not have its accumulated usage reset by its own echo.
1254
+ if (session.activeTurn !== queued) {
1255
+ if (session.activeTurn) {
1256
+ // Hand off the previous turn. If a cancel is pending for it
1257
+ // (its trailing idle hasn't arrived yet), settle it
1258
+ // "cancelled" per the ACP contract rather than "end_turn" —
1259
+ // otherwise a cancel followed quickly by the next prompt
1260
+ // would report the cancelled turn as a normal completion.
1261
+ settleActive(session.cancelled
1262
+ ? { stopReason: "cancelled" }
1263
+ : { stopReason: "end_turn", usage: sessionUsage(session) });
1264
+ }
1265
+ activateTurn(queued);
1266
+ }
1019
1267
  break;
1020
1268
  }
1021
- const pending = session.pendingMessages.get(message.uuid);
1022
- if (pending) {
1023
- pending.resolve(false);
1024
- session.pendingMessages.delete(message.uuid);
1025
- handedOff = true;
1026
- // the current loop stops with end_turn,
1027
- // the loop of the next prompt continues running
1028
- return { stopReason: "end_turn", usage: sessionUsage(session) };
1029
- }
1030
1269
  if ("isReplay" in message && message.isReplay) {
1031
- // not pending or unrelated replay message
1270
+ // Unrelated replay (e.g. the echo of an already-settled turn).
1032
1271
  break;
1033
1272
  }
1034
1273
  }
1274
+ if (session.cancelled) {
1275
+ break;
1276
+ }
1035
1277
  // Snapshot the latest top-level assistant usage and model so the
1036
1278
  // next `result` can emit a usage_update tied to the right context
1037
1279
  // window. Subagent messages are excluded to keep the snapshot
@@ -1096,7 +1338,8 @@ export class ClaudeAcpAgent {
1096
1338
  message.message.content.length === 1 &&
1097
1339
  message.message.content[0].type === "text" &&
1098
1340
  message.message.content[0].text.includes("Please run /login")) {
1099
- throw RequestError.authRequired();
1341
+ failActive(RequestError.authRequired());
1342
+ break;
1100
1343
  }
1101
1344
  let content;
1102
1345
  if (message.type === "assistant" && message.parent_tool_use_id === null) {
@@ -1189,83 +1432,36 @@ export class ClaudeAcpAgent {
1189
1432
  break;
1190
1433
  }
1191
1434
  }
1192
- throw new Error("Session did not end in result");
1435
+ // `while (true)` only exits via the `done` return above or the catch
1436
+ // below, so there is no normal fall-through here.
1193
1437
  }
1194
1438
  catch (error) {
1195
- errored = true;
1196
- // A failed turn typically leaves a trailing `session_state_changed: idle`
1197
- // (and possibly more) in the query iterator. If we don't drain it here,
1198
- // the next prompt's first `query.next()` consumes that stale idle and
1199
- // short-circuits to end_turn with zero usage
1200
- // Bounded so a misbehaving SDK can't hang the next prompt indefinitely.
1201
- try {
1202
- await session.query.interrupt();
1203
- const MAX_DRAIN = 100;
1204
- for (let i = 0; i < MAX_DRAIN; i++) {
1205
- const { value: m, done } = await session.query.next();
1206
- if (done || !m)
1207
- break;
1208
- if (m.type === "system" && m.subtype === "session_state_changed" && m.state === "idle") {
1209
- break;
1210
- }
1211
- if (i === MAX_DRAIN - 1) {
1212
- this.logger.error(`Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`);
1213
- }
1214
- }
1215
- }
1216
- catch (drainErr) {
1217
- this.logger.error(`Session ${params.sessionId}: failed to drain query after prompt error:`, drainErr);
1218
- }
1219
- if (error instanceof RequestError || !(error instanceof Error)) {
1220
- throw error;
1221
- }
1222
- const message = error.message;
1223
- if (message.includes("ProcessTransport") ||
1224
- message.includes("terminated process") ||
1225
- message.includes("process exited with") ||
1226
- message.includes("process terminated by signal") ||
1227
- message.includes("Failed to write to process stdin")) {
1439
+ // The query stream itself died (a transport/process error surfaced from
1440
+ // query.next()). Turn-level failures (auth, error results) are handled
1441
+ // inline via failActive and never reach here. Reject every in-flight turn;
1442
+ // if the process is gone, tear the session down so the client starts fresh.
1443
+ const message = error instanceof Error ? error.message : String(error);
1444
+ const processDied = error instanceof Error &&
1445
+ (message.includes("ProcessTransport") ||
1446
+ message.includes("terminated process") ||
1447
+ message.includes("process exited with") ||
1448
+ message.includes("process terminated by signal") ||
1449
+ message.includes("Failed to write to process stdin"));
1450
+ // Either way the query iterator is finished and the consumer is exiting,
1451
+ // so release its resources via closeQueryStream (idempotent). A process
1452
+ // death is unrecoverable, so additionally evict the session so the client
1453
+ // starts fresh; other stream errors keep the session so prompt()/cancel()
1454
+ // can answer with a clear "session ended" error.
1455
+ if (processDied) {
1228
1456
  this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
1229
- session.settingsManager.dispose();
1230
- session.input.end();
1457
+ failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
1458
+ this.closeQueryStream(session);
1231
1459
  delete this.sessions[params.sessionId];
1232
- throw RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session.");
1233
1460
  }
1234
- throw error;
1235
- }
1236
- finally {
1237
- // The loop is returning — interrupt() succeeded or the prompt finished
1238
- // — so disarm the force-cancel backstop and release the wake-up channel
1239
- // (only if we still own it; a handoff installs the next prompt's).
1240
- if (session.forceCancelTimer) {
1241
- clearTimeout(session.forceCancelTimer);
1242
- session.forceCancelTimer = undefined;
1243
- }
1244
- if (session.cancelController === cancelController) {
1245
- session.cancelController = undefined;
1246
- }
1247
- if (!handedOff) {
1248
- session.promptRunning = false;
1249
- if (errored) {
1250
- // The query stream was just drained — handing pending prompts off
1251
- // onto it would let them race with the recovery. Cancel them so
1252
- // each waiting prompt() returns stopReason: "cancelled" and the
1253
- // client can decide whether to retry.
1254
- for (const pending of session.pendingMessages.values()) {
1255
- pending.resolve(true);
1256
- }
1257
- session.pendingMessages.clear();
1258
- }
1259
- else if (session.pendingMessages.size > 0) {
1260
- // This usually should not happen, but in case the loop finishes
1261
- // without claude sending all message replays, we resolve the
1262
- // next pending prompt call to ensure no prompts get stuck.
1263
- const next = [...session.pendingMessages.entries()].sort((a, b) => a[1].order - b[1].order)[0];
1264
- if (next) {
1265
- next[1].resolve(false);
1266
- session.pendingMessages.delete(next[0]);
1267
- }
1268
- }
1461
+ else {
1462
+ this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
1463
+ failAllTurns(error);
1464
+ this.closeQueryStream(session);
1269
1465
  }
1270
1466
  }
1271
1467
  }
@@ -1274,23 +1470,46 @@ export class ClaudeAcpAgent {
1274
1470
  if (!session) {
1275
1471
  return;
1276
1472
  }
1473
+ // The stream already ended (see closeQueryStream): every in-flight turn was
1474
+ // settled when it closed, and there is no live query to interrupt. Calling
1475
+ // query.interrupt() on a finished iterator could reject and surface from
1476
+ // this fire-and-forget notification, so there is nothing to do here.
1477
+ if (session.queryClosed) {
1478
+ return;
1479
+ }
1277
1480
  session.cancelled = true;
1278
- for (const [, pending] of session.pendingMessages) {
1279
- pending.resolve(true);
1280
- }
1281
- session.pendingMessages.clear();
1282
- // Arm a backstop before interrupting: if a prompt is actively consuming
1283
- // the query and interrupt() doesn't make the SDK yield (e.g. a wedged
1284
- // TaskOutput block — issue #680), force the loop to return "cancelled"
1285
- // after the floor elapses so the pending session/prompt still resolves per
1286
- // the ACP cancellation contract instead of hanging forever. The loop's
1287
- // `finally` clears this timer when interrupt() works and it returns through
1481
+ // Settle queued turns that haven't started yet (no echo seen) right away —
1482
+ // they have no in-flight SDK work to interrupt. The active turn is settled
1483
+ // by the consumer when it observes the interrupt's trailing idle (or via the
1484
+ // backstop below). Mirrors the old pendingMessages cancellation.
1485
+ if (session.turnQueue) {
1486
+ let orphaned = 0;
1487
+ for (const turn of session.turnQueue) {
1488
+ if (turn !== session.activeTurn && !turn.settled) {
1489
+ turn.settled = true;
1490
+ turn.resolve({ stopReason: "cancelled" });
1491
+ orphaned++;
1492
+ }
1493
+ }
1494
+ // Each removed queued turn's user message was already pushed to the SDK,
1495
+ // which processes input FIFO and will still emit a result for it with no
1496
+ // uuid to match. Count those so the consumer skips them (see
1497
+ // ensureActiveTurn) rather than misattributing them to the head.
1498
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
1499
+ session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
1500
+ }
1501
+ // Arm a backstop before interrupting: if a turn is actively consuming the
1502
+ // query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
1503
+ // block — issue #680), force the consumer to settle the active turn
1504
+ // "cancelled" after the floor elapses so the pending session/prompt still
1505
+ // resolves per the ACP cancellation contract instead of hanging forever. The
1506
+ // consumer clears this timer when interrupt() works and it settles through
1288
1507
  // the normal idle path, so on healthy cancels it is armed but never fires.
1289
1508
  //
1290
- // Arm at most once per turn: the floor is an absolute ceiling from the
1291
- // first cancel, so a client that re-sends cancel (each call still retries
1509
+ // Arm at most once per turn: the floor is an absolute ceiling from the first
1510
+ // cancel, so a client that re-sends cancel (each call still retries
1292
1511
  // interrupt() below) can't keep pushing the deadline out.
1293
- if (session.promptRunning &&
1512
+ if (session.activeTurn &&
1294
1513
  session.cancelController &&
1295
1514
  !session.cancelController.signal.aborted &&
1296
1515
  !session.forceCancelTimer) {
@@ -1302,8 +1521,37 @@ export class ClaudeAcpAgent {
1302
1521
  }
1303
1522
  await session.query.interrupt();
1304
1523
  }
1305
- /** Cleanly tear down a session: cancel in-flight work, dispose resources,
1306
- * and remove it from the session map. */
1524
+ /** Mark a session's SDK query stream as permanently ended and release the
1525
+ * resources tied to it: drop the consumer handle, dispose the settings
1526
+ * watchers, end the input stream, and close the query (which terminates the
1527
+ * subprocess). The query iterator is not revivable, so `prompt()`/`cancel()`
1528
+ * consult `queryClosed` and fail/short-circuit instead of acting on a dead
1529
+ * stream. Idempotent (guarded by `queryClosed`), so the consumer's done/error
1530
+ * paths and a later `teardownSession` can all call it without double-releasing.
1531
+ *
1532
+ * Deliberately does NOT abort `session.abortController`: that controller may be
1533
+ * CLIENT-supplied (`_meta.claudeCode.options.abortController`) and reused, so
1534
+ * aborting it on a spontaneous stream end would cancel the client's own work
1535
+ * or make a sibling session born aborted. `query.close()` already terminates
1536
+ * the subprocess; aborting the signal belongs in `teardownSession` (explicit
1537
+ * destroy), not here. Also does NOT remove the session from the map — that is
1538
+ * `teardownSession`'s job — so prompt() can still answer with a clear "session
1539
+ * ended" error after an unexpected stream close. The leftover session object
1540
+ * is a lightweight husk (its heavy resources are released here) and is evicted
1541
+ * on the next closeSession/deleteSession or when the connection's `dispose()`
1542
+ * runs. */
1543
+ closeQueryStream(session) {
1544
+ if (session.queryClosed) {
1545
+ return;
1546
+ }
1547
+ session.queryClosed = true;
1548
+ session.consumer = undefined;
1549
+ session.settingsManager.dispose();
1550
+ session.input.end();
1551
+ session.query.close();
1552
+ }
1553
+ /** Cleanly tear down a session: cancel in-flight work, release stream
1554
+ * resources, and remove it from the session map. */
1307
1555
  async teardownSession(sessionId) {
1308
1556
  const session = this.sessions[sessionId];
1309
1557
  if (!session) {
@@ -1311,20 +1559,23 @@ export class ClaudeAcpAgent {
1311
1559
  }
1312
1560
  await this.cancel({ sessionId });
1313
1561
  // cancel() arms the force-cancel floor and interrupts gracefully, but a
1314
- // wedged prompt loop only wakes when `cancelController` aborts — closing
1315
- // the query/abortController below doesn't touch it. Since we're tearing the
1316
- // session down anyway, wake the loop now so the in-flight prompt() resolves
1317
- // immediately instead of after the floor, and clear the timer so it can't
1318
- // outlive the deleted session (it isn't unref'd and would otherwise keep
1319
- // the event loop alive until it fires).
1562
+ // wedged consumer only wakes when `cancelController` aborts — closeQueryStream
1563
+ // below doesn't touch it. Since we're tearing the session down anyway, wake
1564
+ // the consumer now so the in-flight prompt() resolves immediately instead of
1565
+ // after the floor, and clear the timer so it can't outlive the deleted
1566
+ // session (it isn't unref'd and would otherwise keep the event loop alive
1567
+ // until it fires).
1320
1568
  if (session.forceCancelTimer) {
1321
1569
  clearTimeout(session.forceCancelTimer);
1322
1570
  session.forceCancelTimer = undefined;
1323
1571
  }
1324
1572
  session.cancelController?.abort();
1325
- session.settingsManager.dispose();
1573
+ this.closeQueryStream(session);
1574
+ // Abort the SDK abort signal only on explicit destroy. closeQueryStream
1575
+ // leaves it alone (it may be a client-owned controller — see its doc), but
1576
+ // here the client has asked us to close the session, so signalling abort is
1577
+ // appropriate; query.close() above has already torn the subprocess down.
1326
1578
  session.abortController.abort();
1327
- session.query.close();
1328
1579
  delete this.sessions[sessionId];
1329
1580
  }
1330
1581
  /** Tear down all active sessions. Called when the ACP connection closes. */
@@ -1348,9 +1599,16 @@ export class ClaudeAcpAgent {
1348
1599
  return {};
1349
1600
  }
1350
1601
  async setSessionMode(params) {
1351
- if (!this.sessions[params.sessionId]) {
1602
+ const session = this.sessions[params.sessionId];
1603
+ if (!session) {
1352
1604
  throw new Error("Session not found");
1353
1605
  }
1606
+ // The SDK query stream already ended (see closeQueryStream); the session is
1607
+ // a husk and `query.setPermissionMode` below would act on a closed query.
1608
+ // Fail with the same clear message prompt()/cancel() give for a dead stream.
1609
+ if (session.queryClosed) {
1610
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1611
+ }
1354
1612
  await this.applySessionMode(params.sessionId, params.modeId);
1355
1613
  await this.updateConfigOption(params.sessionId, "mode", params.modeId);
1356
1614
  return {};
@@ -1360,6 +1618,13 @@ export class ClaudeAcpAgent {
1360
1618
  if (!session) {
1361
1619
  throw new Error("Session not found");
1362
1620
  }
1621
+ // The SDK query stream already ended (see closeQueryStream); the session is
1622
+ // a husk and the `query.setModel`/`setPermissionMode`/`applyFlagSettings`
1623
+ // calls this triggers would act on a closed query. Fail with the same clear
1624
+ // message prompt()/cancel() give for a dead stream.
1625
+ if (session.queryClosed) {
1626
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1627
+ }
1363
1628
  if (typeof params.value !== "string") {
1364
1629
  throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
1365
1630
  }
@@ -2163,9 +2428,6 @@ export class ClaudeAcpAgent {
2163
2428
  models,
2164
2429
  modelInfos: allowedModels,
2165
2430
  configOptions,
2166
- promptRunning: false,
2167
- pendingMessages: new Map(),
2168
- nextPendingOrder: 0,
2169
2431
  abortController,
2170
2432
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
2171
2433
  contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
@@ -1 +1 @@
1
- {"version":3,"file":"elicitation.d.ts","sourceRoot":"","sources":["../src/elicitation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAI1B,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC5F,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,6CAA6C,CAAC;AAErD;;;;;;;;;;;;;GAaG;AAEH,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,kBAAkB,EAC3B,SAAS,EAAE,MAAM,GAChB,wBAAwB,GAAG,IAAI,CAyBjC;AAED;;;GAGG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,yBAAyB,GAClC,iBAAiB,CAUnB;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,EAAE,GAAG,IAAI,CAUhG;AAgBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,+BAA+B,CAC7C,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,wBAAwB,CAyC1B;AAED,+EAA+E;AAC/E,MAAM,MAAM,sBAAsB,GAC9B;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC7D;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEzB;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,yBAAyB,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,eAAe,EAAE,GAC3B,sBAAsB,CAkCxB"}
1
+ {"version":3,"file":"elicitation.d.ts","sourceRoot":"","sources":["../src/elicitation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAI1B,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC5F,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,6CAA6C,CAAC;AAErD;;;;;;;;;;;;;GAaG;AAEH,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,kBAAkB,EAC3B,SAAS,EAAE,MAAM,GAChB,wBAAwB,GAAG,IAAI,CAyBjC;AAED;;;GAGG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,yBAAyB,GAClC,iBAAiB,CAUnB;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,EAAE,GAAG,IAAI,CAUhG;AA4CD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,+BAA+B,CAC7C,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,wBAAwB,CAwD1B;AAED,+EAA+E;AAC/E,MAAM,MAAM,sBAAsB,GAC9B;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC7D;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEzB;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,yBAAyB,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,eAAe,EAAE,GAC3B,sBAAsB,CAkCxB"}
@@ -70,6 +70,27 @@ function questionFieldKey(index) {
70
70
  function questionCustomFieldKey(index) {
71
71
  return `question_${index}_custom`;
72
72
  }
73
+ /**
74
+ * `_meta` key under which a bridged enum option carries its structured
75
+ * `description`/`preview`, since ACP's `EnumOption` has no field for either.
76
+ * Namespaced like the agent's other `_meta` extensions (`_claude/...`).
77
+ */
78
+ const OPTION_META_KEY = "_claude/askUserQuestionOption";
79
+ /**
80
+ * Build the `_meta` payload that carries an option's structured `description`
81
+ * and optional `preview` for clients that can render them as distinct fields.
82
+ * Returns `undefined` when neither is present, so we don't emit an empty `_meta`.
83
+ */
84
+ function askUserQuestionOptionMeta(option) {
85
+ const detail = {};
86
+ if (option.description) {
87
+ detail.description = option.description;
88
+ }
89
+ if (option.preview) {
90
+ detail.preview = option.preview;
91
+ }
92
+ return Object.keys(detail).length > 0 ? { [OPTION_META_KEY]: detail } : undefined;
93
+ }
73
94
  /**
74
95
  * Render the AskUserQuestion tool's questions as an ACP form elicitation.
75
96
  *
@@ -89,10 +110,25 @@ export function askUserQuestionsToCreateRequest(questions, sessionId, toolCallId
89
110
  const single = questions.length === 1;
90
111
  const properties = {};
91
112
  questions.forEach((question, index) => {
92
- const options = question.options.map((option) => ({
93
- const: option.label,
94
- title: option.description ? `${option.label} — ${option.description}` : option.label,
95
- }));
113
+ const options = question.options.map((option) => {
114
+ const enumOption = {
115
+ const: option.label,
116
+ // `title` stays a flattened "label — description" string so clients that
117
+ // only read `const`/`title` still surface the description. Clients that
118
+ // understand the `_meta` below should prefer its structured fields (and
119
+ // treat `const` as the clean label) rather than parsing this title.
120
+ title: option.description ? `${option.label} — ${option.description}` : option.label,
121
+ };
122
+ // The SDK option carries a `description` (secondary text) and an optional
123
+ // `preview` (mockups, code snippets, comparisons shown on focus). Neither
124
+ // has a structural slot in `EnumOption`, so forward them under ACP's
125
+ // reserved `_meta` extension point for clients that can render them.
126
+ const meta = askUserQuestionOptionMeta(option);
127
+ if (meta) {
128
+ enumOption._meta = meta;
129
+ }
130
+ return enumOption;
131
+ });
96
132
  // For a single question the prompt is carried by `message`, so we don't
97
133
  // repeat it in the field description. With multiple questions each field
98
134
  // needs its own question text.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.45.1",
6
+ "version": "0.46.0",
7
7
  "description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
8
8
  "main": "dist/lib.js",
9
9
  "types": "dist/lib.d.ts",
@@ -60,16 +60,16 @@
60
60
  "author": "Zed Industries",
61
61
  "license": "Apache-2.0",
62
62
  "dependencies": {
63
- "@agentclientprotocol/sdk": "0.25.1",
64
- "@anthropic-ai/claude-agent-sdk": "0.3.177",
63
+ "@agentclientprotocol/sdk": "0.26.0",
64
+ "@anthropic-ai/claude-agent-sdk": "0.3.178",
65
65
  "zod": "^3.25.0 || ^4.0.0"
66
66
  },
67
67
  "devDependencies": {
68
- "@anthropic-ai/sdk": "0.104.1",
68
+ "@anthropic-ai/sdk": "0.104.2",
69
69
  "@eslint/js": "10.0.1",
70
70
  "@types/node": "25.9.3",
71
- "@typescript-eslint/eslint-plugin": "8.61.0",
72
- "@typescript-eslint/parser": "8.61.0",
71
+ "@typescript-eslint/eslint-plugin": "8.61.1",
72
+ "@typescript-eslint/parser": "8.61.1",
73
73
  "eslint": "10.5.0",
74
74
  "eslint-config-prettier": "10.1.8",
75
75
  "globals": "17.6.0",