@agentclientprotocol/claude-agent-acp 0.89.1 → 0.89.2-preview.10

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.
package/dist/acp-agent.js CHANGED
@@ -32,7 +32,7 @@ import { ContextCompactionLifecycle, clientSupportsCompactionUpdates, contextCom
32
32
  import { clientSupportsNotices, MAX_NOTICE_TITLE_LENGTH, normalizeNoticeText, noticeOrTranscriptUpdate, sentenceCase, splitNoticeText, } from "./session-notices.js";
33
33
  import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, hasHookCallback, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, changedTaskPlanEntries, forgetPublishedTaskPlan, unregisterHookCallback, } from "./tools.js";
34
34
  import { previewPatchContent } from "./diff.js";
35
- import { backgroundedBashToolCall } from "./tool-calls/background.js";
35
+ import { backgroundedBashToolCall, withRunningTerminal } from "./tool-calls/background.js";
36
36
  import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
37
37
  import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
38
38
  import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
@@ -280,6 +280,10 @@ const AUTONOMOUS_RESULT_ORIGINS = new Set([
280
280
  "observer",
281
281
  "observer-activity",
282
282
  ]);
283
+ /** The foreground work of a session (see ForegroundEvents): a prompt's turn,
284
+ * or a model cycle that Claude Code started on its own. At most one cycle
285
+ * runs at a time, so a constant names it. */
286
+ const AUTONOMOUS_CYCLE = "autonomous-cycle";
283
287
  /** Whether this turn's terminal result arrived but its settlement is being
284
288
  * held for background subagents it spawned (see Turn.deferredSettle). The
285
289
  * single spelling of the hold predicate, shared by the consumer's settle
@@ -1194,6 +1198,15 @@ export class ClaudeAcpAgent {
1194
1198
  * express: see {@link ToolCallClientCapabilities.v2}.
1195
1199
  */
1196
1200
  v2;
1201
+ /** See the `foreground` constructor option. */
1202
+ foregroundEvents;
1203
+ /**
1204
+ * The foreground work of each session while it runs, by the session id that
1205
+ * the client knows. A clear-context restart replaces the Claude Code session
1206
+ * behind that id and moves the running turn to it, so the work belongs to the
1207
+ * id, not to a {@link Session}.
1208
+ */
1209
+ foregroundWork = new Map();
1197
1210
  /** The session index (docs/air-extensions.md, "Session index"). */
1198
1211
  sessionIndex = new sessionIndex.SessionIndexConnection({
1199
1212
  agent: this,
@@ -1203,6 +1216,7 @@ export class ClaudeAcpAgent {
1203
1216
  });
1204
1217
  constructor(client, logger, options = {}) {
1205
1218
  this.v2 = options.v2 ?? false;
1219
+ this.foregroundEvents = options.foreground;
1206
1220
  this.sessions = {};
1207
1221
  this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client);
1208
1222
  this.logger = logger ?? console;
@@ -1987,13 +2001,7 @@ export class ClaudeAcpAgent {
1987
2001
  async prompt(params) {
1988
2002
  let events;
1989
2003
  const outcome = new Promise((resolve, reject) => {
1990
- events = {
1991
- inserted() { },
1992
- awaitingUser() { },
1993
- resumed() { },
1994
- ended: resolve,
1995
- failed: reject,
1996
- };
2004
+ events = { inserted() { }, ended: resolve, failed: reject };
1997
2005
  });
1998
2006
  await this.startTurn(params, events);
1999
2007
  return outcome;
@@ -2066,10 +2074,12 @@ export class ClaudeAcpAgent {
2066
2074
  completeTurn = resolve;
2067
2075
  }),
2068
2076
  resolve: (outcome) => {
2077
+ this.endForeground(params.sessionId, turn, { outcome });
2069
2078
  events.ended(outcome);
2070
2079
  completeTurn();
2071
2080
  },
2072
2081
  reject: (error) => {
2082
+ this.endForeground(params.sessionId, turn, { error });
2073
2083
  events.failed(error);
2074
2084
  completeTurn();
2075
2085
  },
@@ -2387,7 +2397,7 @@ export class ClaudeAcpAgent {
2387
2397
  // Deliver into the running turn rather than queuing behind it as a fresh
2388
2398
  // prompt would.
2389
2399
  userMessage.priority =
2390
- (session.pendingUserInputCount ?? 0) > 0 ? STEER_PRIORITY_LATER : STEER_PRIORITY_NOW;
2400
+ (session.openUserInputs?.size ?? 0) > 0 ? STEER_PRIORITY_LATER : STEER_PRIORITY_NOW;
2391
2401
  // Mark before the push and in the same synchronous section as the in-flight
2392
2402
  // check: the interrupt can have the CLI finalizing the aborted cycle by the
2393
2403
  // time the consumer next runs, and an unmarked result would settle the turn
@@ -2400,6 +2410,8 @@ export class ClaudeAcpAgent {
2400
2410
  if (turnInFlight.deferredSettle !== undefined) {
2401
2411
  turnInFlight.steeredSettle = turnInFlight.deferredSettle;
2402
2412
  turnInFlight.deferredSettle = undefined;
2413
+ // The held turn's work had ended at its result; the steer resumes it.
2414
+ this.startForeground(sessionId, turnInFlight);
2403
2415
  }
2404
2416
  session.input.push(userMessage);
2405
2417
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
@@ -2706,6 +2718,7 @@ export class ClaudeAcpAgent {
2706
2718
  const activateTurn = (turn) => {
2707
2719
  session.activeTurn = turn;
2708
2720
  session.cancelled = false;
2721
+ session.cancelledPrompts = undefined;
2709
2722
  compaction.resume();
2710
2723
  if (turn.localCommand?.startsAtActivation)
2711
2724
  ensureLocalCommandMarkdown(turn);
@@ -2742,7 +2755,7 @@ export class ClaudeAcpAgent {
2742
2755
  reportInserted(turn);
2743
2756
  // A request can already be open: Claude Code asks for a queued prompt
2744
2757
  // before the consumer reaches its echo.
2745
- this.syncAwaitingUser(session);
2758
+ this.syncAwaitingUser(params.sessionId, session);
2746
2759
  };
2747
2760
  /** Report that Claude Code took the turn's prompt in, once. A turn can be
2748
2761
  * settled by now: an echo hand-off awaits settling the previous turn, and
@@ -2752,6 +2765,7 @@ export class ClaudeAcpAgent {
2752
2765
  return;
2753
2766
  turn.insertedReported = true;
2754
2767
  turn.events.inserted(turn.promptUuid);
2768
+ this.startForeground(params.sessionId, turn);
2755
2769
  };
2756
2770
  /** Ensure there is an active turn before a user-turn result that carries no
2757
2771
  * echo to activate it, by promoting the queue head. Most turns are
@@ -2776,7 +2790,7 @@ export class ClaudeAcpAgent {
2776
2790
  * a stamp naming an orphaned command consumes the result outright, and a
2777
2791
  * stamp naming anything else positively refutes "this is a dead turn's
2778
2792
  * result", so the dup-over-loss one-skip must not eat it. */
2779
- const ensureActiveTurn = async (resultUserMessageUuid) => {
2793
+ const ensureActiveTurn = async (resultUserMessageUuid, resultUserMessageUuids) => {
2780
2794
  if (session.activeTurn) {
2781
2795
  if (!isHeldOpen(session.activeTurn)) {
2782
2796
  return;
@@ -2802,8 +2816,8 @@ export class ClaudeAcpAgent {
2802
2816
  }
2803
2817
  // Orphan accounting runs BEFORE the head check: an orphan's echo-less
2804
2818
  // result can arrive with an EMPTY queue (the common post-cancel
2805
- // timeline — the active turn settled at the interrupt's idle and the
2806
- // user hasn't typed yet), and it must still be consumed here. Skipping
2819
+ // timeline — the cancelled turn settled at its interrupted result and
2820
+ // the user hasn't typed yet), and it must still be consumed here. Skipping
2807
2821
  // the bookkeeping when there is nothing to promote would leave a
2808
2822
  // phantom entry/count that swallows the next live echo-less result
2809
2823
  // (e.g. /compact) instead.
@@ -2872,6 +2886,14 @@ export class ClaudeAcpAgent {
2872
2886
  }
2873
2887
  }
2874
2888
  }
2889
+ // A result that names a prompt a cancel stopped is that prompt's, even
2890
+ // when the orphan accounting above lost track of it: the interrupt
2891
+ // receipt leaves out a prompt that Claude Code had already taken off its
2892
+ // queue. It must not settle the prompt sent after the cancel.
2893
+ const stamps = resultUserMessageUuids ??
2894
+ (resultUserMessageUuid !== undefined ? [resultUserMessageUuid] : []);
2895
+ if (stamps.some((uuid) => session.cancelledPrompts?.has(uuid)))
2896
+ return;
2875
2897
  const head = firstUnsettledQueuedTurn();
2876
2898
  if (!head) {
2877
2899
  return;
@@ -2971,17 +2993,53 @@ export class ClaudeAcpAgent {
2971
2993
  };
2972
2994
  /** Registers a live background task (see `liveBackgroundTasks`). A
2973
2995
  * subagent is also recorded on the active turn, so that turn holds until
2974
- * the subagent settles (see `Turn.spawnedTaskIds`). */
2975
- const registerLiveTask = (taskId, parentToolUseId, isSubagent) => {
2976
- session.liveBackgroundTasks.set(taskId, { parentToolUseId, isSubagent });
2996
+ * the subagent settles (see `Turn.spawnedTaskIds`). Its requests may
2997
+ * already be open, and where it runs decides whether they block the
2998
+ * foreground work, so that is checked again. */
2999
+ const registerLiveTask = (taskId, parentToolUseId, isSubagent, placement = {}) => {
3000
+ session.liveBackgroundTasks.set(taskId, {
3001
+ parentToolUseId,
3002
+ isSubagent,
3003
+ ...(placement.backgrounded !== undefined ? { backgrounded: placement.backgrounded } : {}),
3004
+ ...(placement.parentTaskId !== undefined ? { parentTaskId: placement.parentTaskId } : {}),
3005
+ });
2977
3006
  if (parentToolUseId)
2978
3007
  forgetForegroundToolCall(session, parentToolUseId);
2979
3008
  session.resumableSubagents?.delete(taskId);
2980
3009
  if (isSubagent && session.activeTurn && !session.activeTurn.settled) {
2981
3010
  (session.activeTurn.spawnedTaskIds ??= new Set()).add(taskId);
2982
3011
  }
3012
+ this.syncAwaitingUser(params.sessionId, session);
3013
+ };
3014
+ /** Ends the display terminal of a command that went to the background, once
3015
+ * its task ends (see `Session.backgroundTerminals`). Claude Code reports no
3016
+ * exit code for it, and `completed` does not mean 0: Claude Code accepts
3017
+ * some non-zero codes as a success, such as grep's 1. */
3018
+ const endBackgroundTerminal = async (taskId) => {
3019
+ const terminal = session.backgroundTerminals?.get(taskId);
3020
+ if (!terminal)
3021
+ return;
3022
+ session.backgroundTerminals.delete(taskId);
3023
+ await sendUpdate({
3024
+ sessionId: params.sessionId,
3025
+ update: {
3026
+ sessionUpdate: "tool_call_update",
3027
+ toolCallId: terminal.toolCallId,
3028
+ _meta: {
3029
+ ...(terminal.parentToolUseId
3030
+ ? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
3031
+ : {}),
3032
+ terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null },
3033
+ },
3034
+ },
3035
+ });
3036
+ };
3037
+ /** Settles a task. Its request may still be open, and an agent without a
3038
+ * record no longer blocks the foreground work, so that is checked again. */
3039
+ const settleLiveTask = (taskId) => {
3040
+ settleLiveBackgroundTask(session, taskId);
3041
+ this.syncAwaitingUser(params.sessionId, session);
2983
3042
  };
2984
- const settleLiveTask = (taskId) => settleLiveBackgroundTask(session, taskId);
2985
3043
  /** Registers a settled subagent again when the SDK resumes it without a
2986
3044
  * new `task_started`. The active turn is the one that resumed it, so it
2987
3045
  * holds until the subagent settles again. A repeated resume signal finds
@@ -2990,8 +3048,9 @@ export class ClaudeAcpAgent {
2990
3048
  if (session.liveBackgroundTasks.has(taskId))
2991
3049
  return;
2992
3050
  const settled = session.resumableSubagents?.get(taskId);
3051
+ // Claude Code always resumes a subagent in the background.
2993
3052
  if (settled)
2994
- registerLiveTask(taskId, settled.parentToolUseId, true);
3053
+ registerLiveTask(taskId, settled.parentToolUseId, true, { backgrounded: true });
2995
3054
  };
2996
3055
  /** Whether any background subagent this turn spawned is still live —
2997
3056
  * while true, the turn's settlement stays deferred so the subagent's
@@ -3044,6 +3103,8 @@ export class ClaudeAcpAgent {
3044
3103
  !session.activeTurn.settled &&
3045
3104
  turnAwaitingSubagents(session.activeTurn)) {
3046
3105
  session.activeTurn.deferredSettle = outcome;
3106
+ // The hold delays v1's answer; the turn's work ended at its result.
3107
+ this.endForeground(params.sessionId, session.activeTurn, { outcome });
3047
3108
  }
3048
3109
  else {
3049
3110
  await settleActive(outcome);
@@ -3067,9 +3128,14 @@ export class ClaudeAcpAgent {
3067
3128
  // Check only this turn's tools. A tool_result removes its id from
3068
3129
  // emittedToolCalls even when its PostToolUse hook has not arrived yet.
3069
3130
  const unfinished = [...(turn.foregroundToolCallIds ?? [])].filter((id) => session.emittedToolCalls.has(id) && !backgroundTools.has(id));
3070
- /** Closes a tool call as failed. Returns false when the turn ended meanwhile. */
3071
- const failToolCall = async (toolCallId, text) => {
3072
- // A late hook must not overwrite the failure we are about to send.
3131
+ const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
3132
+ /**
3133
+ * Closes a tool call that the turn ended without a result for
3134
+ * (`AcpToolCallRenderer.unfinished`). Returns false when the turn
3135
+ * ended meanwhile.
3136
+ */
3137
+ const closeUnfinished = async (toolCallId, ran) => {
3138
+ // A late hook must not overwrite the report we are about to send.
3073
3139
  unregisterHookCallback(toolCallId);
3074
3140
  session.emittedToolCalls.delete(toolCallId);
3075
3141
  delete session.toolUseCache[toolCallId];
@@ -3077,12 +3143,7 @@ export class ClaudeAcpAgent {
3077
3143
  session.dispatchedToolCalls?.delete(toolCallId);
3078
3144
  await sendUpdate({
3079
3145
  sessionId: params.sessionId,
3080
- update: {
3081
- sessionUpdate: "tool_call_update",
3082
- toolCallId,
3083
- status: "failed",
3084
- content: [{ type: "content", content: { type: "text", text } }],
3085
- },
3146
+ update: renderer.unfinished({ toolCallId, ran }),
3086
3147
  });
3087
3148
  // Cancellation can arrive while we await the client update.
3088
3149
  if (turn.settled || session.activeTurn !== turn)
@@ -3099,7 +3160,7 @@ export class ClaudeAcpAgent {
3099
3160
  const abandoned = unfinished.filter((id) => !session.dispatchedToolCalls?.has(id));
3100
3161
  const stuck = unfinished.filter((id) => session.dispatchedToolCalls?.has(id));
3101
3162
  for (const toolCallId of abandoned) {
3102
- if (!(await failToolCall(toolCallId, "Claude stopped this tool call before it ran."))) {
3163
+ if (!(await closeUnfinished(toolCallId, false))) {
3103
3164
  return;
3104
3165
  }
3105
3166
  }
@@ -3108,7 +3169,7 @@ export class ClaudeAcpAgent {
3108
3169
  this.logger.error(`Session ${params.sessionId}, turn ${turn.promptUuid}, stopReason=${result.stopReason}: ${message}`);
3109
3170
  // Fail every unfinished tool before reporting one error for the prompt.
3110
3171
  for (const toolCallId of stuck) {
3111
- if (!(await failToolCall(toolCallId, "Claude ended the turn without returning a result for this tool."))) {
3172
+ if (!(await closeUnfinished(toolCallId, true))) {
3112
3173
  return;
3113
3174
  }
3114
3175
  }
@@ -3220,6 +3281,7 @@ export class ClaudeAcpAgent {
3220
3281
  /** Reject every in-flight turn — used when the stream dies. */
3221
3282
  const failAllTurns = (error) => {
3222
3283
  disarmForceCancel(session);
3284
+ this.endAutonomousCycle(params.sessionId, { error });
3223
3285
  const turns = session.activeTurn
3224
3286
  ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
3225
3287
  : [...(session.turnQueue ?? [])];
@@ -3317,9 +3379,9 @@ export class ClaudeAcpAgent {
3317
3379
  }
3318
3380
  }
3319
3381
  else if (active.commandResultSeen) {
3320
- // Its result was already consumed (dropped at the cancelled
3321
- // guard); only the terminal frame is outstanding, which no-ops
3322
- // with no entry. Nothing to track.
3382
+ // Its result was already consumed (at the cancelled guard);
3383
+ // only the terminal frame is outstanding, which no-ops with no
3384
+ // entry. Nothing to track.
3323
3385
  }
3324
3386
  else {
3325
3387
  // The wedged turn WAS dispatched (it's active), so track it
@@ -3386,6 +3448,9 @@ export class ClaudeAcpAgent {
3386
3448
  // it), but the stored one is the authoritative source.
3387
3449
  const inFlight = session.activeTurn;
3388
3450
  await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "at end of stream");
3451
+ this.endAutonomousCycle(params.sessionId, session.cancelled
3452
+ ? { outcome: { stopReason: "cancelled" } }
3453
+ : { error: RequestError.internalError(undefined, SESSION_ENDED_MESSAGE) });
3389
3454
  await settleActive(session.cancelled
3390
3455
  ? cancelledOutcome(session, inFlight)
3391
3456
  : (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)));
@@ -3528,6 +3593,7 @@ export class ClaudeAcpAgent {
3528
3593
  await this.publishRuntimeGoal(params.sessionId, toGoalSnapshot(activeGoal));
3529
3594
  continue;
3530
3595
  }
3596
+ this.startAutonomousCycle(params.sessionId, session, message);
3531
3597
  switch (message.type) {
3532
3598
  case "system":
3533
3599
  switch (message.subtype) {
@@ -3539,6 +3605,9 @@ export class ClaudeAcpAgent {
3539
3605
  if (message.capabilities?.includes("msg_lifecycle_v1")) {
3540
3606
  session.msgLifecycleV1 = true;
3541
3607
  }
3608
+ if (message.capabilities?.includes("request_marker_lists_v1")) {
3609
+ session.requestMarkerListsV1 = true;
3610
+ }
3542
3611
  // A fresh `system`/init (e.g. after reinitialize) can carry an
3543
3612
  // updated Fast mode state; reconcile it with what we seeded at
3544
3613
  // session creation.
@@ -3574,6 +3643,24 @@ export class ClaudeAcpAgent {
3574
3643
  }
3575
3644
  break;
3576
3645
  case "status": {
3646
+ // A model request that serves none of the prompts a cancel
3647
+ // stopped is new work, such as Claude Code's follow-up after a
3648
+ // background command finished. It comes after the stopped
3649
+ // work's result: Claude Code ends the interrupted turn first,
3650
+ // then runs any prompt it had already taken off its queue (its
3651
+ // request names it, so the cancel still drops it), then its own
3652
+ // follow-ups, whose requests name no prompt.
3653
+ if (message.status === "requesting" &&
3654
+ session.cancelled &&
3655
+ session.requestMarkerListsV1 &&
3656
+ !servesCancelledWork(session, message)) {
3657
+ session.cancelled = false;
3658
+ session.cancelledPrompts = undefined;
3659
+ // The stopped work's tail has drained, as at activation.
3660
+ compaction.resume();
3661
+ }
3662
+ if (message.status === "requesting")
3663
+ session.modelRequestSinceResult = true;
3577
3664
  if (message.status === "compacting") {
3578
3665
  await compaction.start(message.uuid);
3579
3666
  }
@@ -3665,6 +3752,17 @@ export class ClaudeAcpAgent {
3665
3752
  session.owedTrailingIdles = 0;
3666
3753
  }
3667
3754
  if (message.state === "idle") {
3755
+ // A model cycle that Claude Code started on its own normally
3756
+ // ends at its result. An idle that no earlier result owes
3757
+ // means that result will not come (the stream dropped
3758
+ // mid-cycle, as for a turn in issue #825), so the cycle ends
3759
+ // here. An owed idle belongs to an earlier result: it can lag
3760
+ // past the cycle's first output.
3761
+ if (session.owedTrailingIdles === 0) {
3762
+ this.endAutonomousCycle(params.sessionId, {
3763
+ outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" },
3764
+ });
3765
+ }
3668
3766
  // A non-cancelled turn normally settled at its terminal
3669
3767
  // `result` already (issue #773), and that result recorded an
3670
3768
  // owed trailing idle — absorbed here via the decrement. We
@@ -3674,9 +3772,9 @@ export class ClaudeAcpAgent {
3674
3772
  // the time it arrives the SDK may have echoed the NEXT turn
3675
3773
  // and activated it — settling now would resolve that new
3676
3774
  // turn prematurely with end_turn and ~zero usage, dropping
3677
- // its real result. A cancelled turn relies on `idle`: its
3678
- // `result` is dropped at the `session.cancelled` guard, so
3679
- // it never settles at a result and must settle here.
3775
+ // its real result. A cancelled turn settles at its
3776
+ // interrupted result (at the `session.cancelled` guard); one
3777
+ // whose result never came settles here.
3680
3778
  //
3681
3779
  // An idle that is NOT owed while the active turn is still
3682
3780
  // unsettled is the issue #825 signature: `idle` is the SDK's
@@ -3688,12 +3786,9 @@ export class ClaudeAcpAgent {
3688
3786
  // the turn NOW so its session/prompt gets a terminal
3689
3787
  // response, instead of leaving it hanging until the next
3690
3788
  // prompt drains the wreckage.
3691
- // A cancelled turn still consumed tokens: its dropped result
3692
- // already fed the accumulator (the usage tally at the result
3693
- // handler runs before the `session.cancelled` guard), so
3694
- // report it — clients metering spend would otherwise lose
3695
- // the interrupted turn's tokens entirely (issue #844). Zero
3696
- // when the cancel pre-empted the result (wedge/force-cancel).
3789
+ // A cancelled turn reaches this with no result of its own,
3790
+ // so its usage is zero (wedge/force-cancel), or, for a held
3791
+ // turn, the usage its result recorded (issue #844).
3697
3792
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
3698
3793
  // A held turn's result passed long ago; this idle is the
3699
3794
  // trailer of the followup cycle the cancel interrupted,
@@ -3746,8 +3841,11 @@ export class ClaudeAcpAgent {
3746
3841
  // Via the subagent gate, not settleActive: a steered turn
3747
3842
  // can also have spawned background subagents, which own it
3748
3843
  // from here (settles now if none is live, holds otherwise).
3844
+ const outcome = session.activeTurn.steeredSettle;
3749
3845
  const steered = session.activeTurn;
3750
- steered.deferredSettle = steered.steeredSettle;
3846
+ steered.deferredSettle = outcome;
3847
+ // A hold delays v1's answer; the turn's work ends here.
3848
+ this.endForeground(params.sessionId, steered, { outcome });
3751
3849
  leaveSteerLane(steered);
3752
3850
  await settleDeferredIfDrained();
3753
3851
  }
@@ -3962,7 +4060,10 @@ export class ClaudeAcpAgent {
3962
4060
  // turn: a turn only ever waits on its own subagents, and a
3963
4061
  // spawn during a held-open drain window (an agent chain)
3964
4062
  // extends that turn's hold.
3965
- registerLiveTask(message.task_id, message.tool_use_id, !!message.subagent_type);
4063
+ registerLiveTask(message.task_id, message.tool_use_id, !!message.subagent_type, {
4064
+ backgrounded: message.is_backgrounded,
4065
+ parentTaskId: message.parent_task_id,
4066
+ });
3966
4067
  await subagents.taskStarted({
3967
4068
  taskId: message.task_id,
3968
4069
  toolUseId: message.tool_use_id,
@@ -4001,6 +4102,7 @@ export class ClaudeAcpAgent {
4001
4102
  // The subagent streams no more text under this parent.
4002
4103
  streamedBlocksByParent.delete(message.tool_use_id);
4003
4104
  }
4105
+ await endBackgroundTerminal(message.task_id);
4004
4106
  settleLiveTask(message.task_id);
4005
4107
  break;
4006
4108
  case "task_updated":
@@ -4009,6 +4111,14 @@ export class ClaudeAcpAgent {
4009
4111
  break;
4010
4112
  }
4011
4113
  await asyncTasks.taskUpdated(message.task_id, message.patch);
4114
+ // A task moved to the background no longer blocks the tool
4115
+ // call that spawned it, nor its requests the foreground work.
4116
+ if (message.patch.is_backgrounded !== undefined) {
4117
+ const task = session.liveBackgroundTasks.get(message.task_id);
4118
+ if (task)
4119
+ task.backgrounded = message.patch.is_backgrounded;
4120
+ this.syncAwaitingUser(params.sessionId, session);
4121
+ }
4012
4122
  // terminal-status task_updated patch and a (deduplicated)
4013
4123
  // task_notification when a task settles, but only the patch is
4014
4124
  // guaranteed per transition — prune on it too so the registry
@@ -4021,6 +4131,7 @@ export class ClaudeAcpAgent {
4021
4131
  const parentToolUseId = session.liveBackgroundTasks.get(message.task_id)?.parentToolUseId;
4022
4132
  if (parentToolUseId)
4023
4133
  streamedBlocksByParent.delete(parentToolUseId);
4134
+ await endBackgroundTerminal(message.task_id);
4024
4135
  settleLiveTask(message.task_id);
4025
4136
  }
4026
4137
  else if (message.patch.status === "running" ||
@@ -4230,6 +4341,7 @@ export class ClaudeAcpAgent {
4230
4341
  }
4231
4342
  break;
4232
4343
  case "result": {
4344
+ session.modelRequestSinceResult = false;
4233
4345
  // The result ends the model turn. A background task that still
4234
4346
  // waits for its tool call id gets its spawn now, without the id.
4235
4347
  await asyncTasks.releaseHeld();
@@ -4264,7 +4376,7 @@ export class ClaudeAcpAgent {
4264
4376
  // the map in that case).
4265
4377
  if (!isAutonomousResult) {
4266
4378
  recordResultForOrphanCommands();
4267
- await ensureActiveTurn(message.user_message_uuid);
4379
+ await ensureActiveTurn(message.user_message_uuid, message.user_message_uuids);
4268
4380
  // A result that names a steer consumed it (see
4269
4381
  // Turn.steeredEchoes), so the steered work is done: retire
4270
4382
  // its echo — the CLI may never have replayed it — and, with
@@ -4325,13 +4437,10 @@ export class ClaudeAcpAgent {
4325
4437
  // orphan skip) and the SDK follows it with a trailing
4326
4438
  // `session_state_changed: idle` — record the debt so the idle
4327
4439
  // handler absorbs that idle rather than reading it as a turn the
4328
- // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
4329
- // turn's own result. It is dropped at the `session.cancelled`
4330
- // guard, and either the idle itself settles the turn (consuming
4331
- // the trailer) or the next echo's hand-off does (which records
4332
- // the debt there instead) — counting here too would double it.
4333
- // Results skipped while cancelled with NO active turn — orphaned
4334
- // queued turns the SDK still ran, or a force-cancelled turn's
4440
+ // SDK abandoned (issue #825). That includes the result of a
4441
+ // cancelled turn, which settles it at the `session.cancelled`
4442
+ // guard below. Results skipped while cancelled with NO active
4443
+ // turn — orphaned queued turns the SDK still ran, or a force-cancelled turn's
4335
4444
  // late result after the backstop settled it — get no such settle,
4336
4445
  // so their trailers must be counted here or they'd later be read
4337
4446
  // as the next healthy turn being abandoned and false-fail it.
@@ -4344,20 +4453,17 @@ export class ClaudeAcpAgent {
4344
4453
  // but a held turn settling AT a followup result unblocks the
4345
4454
  // client at exactly that point, making the race the common
4346
4455
  // case.
4347
- // The cancelled-ACTIVE-turn exclusion applies only to that
4348
- // turn's OWN result — a followup result arriving inside the
4349
- // cancel window still gets its own trailer and must be counted,
4350
- // or that idle would later false-fail the next prompt.
4351
- // A second exclusion: a steered turn's own results (see
4456
+ // One exclusion: a steered turn's own results (see
4352
4457
  // Turn.steeredEchoes). One idle covers the whole interrupted +
4353
4458
  // steered sequence and that idle settles the turn, so counting
4354
4459
  // either result would leave a debt that swallows it. Autonomous
4355
4460
  // results inside a steered turn keep their own trailers, and so
4356
4461
  // does a result that named the steer: it took the turn out of the
4357
- // steer lane above and settles at the result, not the idle.
4358
- const owesTrailingIdle = isAutonomousResult || !isSteering(session.activeTurn);
4359
- if (owesTrailingIdle &&
4360
- (isAutonomousResult || !session.cancelled || !session.activeTurn)) {
4462
+ // steer lane above and settles at the result, not the idle. So
4463
+ // does a cancelled steered turn's result, which settles it at the
4464
+ // `session.cancelled` guard below.
4465
+ const owesTrailingIdle = isAutonomousResult || !isSteering(session.activeTurn) || session.cancelled;
4466
+ if (owesTrailingIdle) {
4361
4467
  session.owedTrailingIdles++;
4362
4468
  }
4363
4469
  // Accumulate usage into the user turn's tally. Skip autonomous
@@ -4443,6 +4549,26 @@ export class ClaudeAcpAgent {
4443
4549
  }),
4444
4550
  });
4445
4551
  }
4552
+ // A model cycle that Claude Code started on its own ends at its
4553
+ // result, after its usage, also after a cancel. A turn that
4554
+ // Claude Code takes in takes the cycle over, so a cycle that
4555
+ // still runs owns this result, whatever its origin says. A
4556
+ // placeholder result (`num_turns: 0`, see the autonomous lane
4557
+ // below) comes before the shared followup runs, so it ends
4558
+ // nothing. The diagnostic of an ExitPlanMode answer that
4559
+ // interrupted the cycle ends it `cancelled`, as it ends a turn,
4560
+ // unless a held turn continues the accepted plan in a fresh
4561
+ // context: that turn takes the cycle over (see the autonomous
4562
+ // lane below).
4563
+ const exitPlanInterruption = pendingExitPlanModeInterruption !== undefined &&
4564
+ isExitPlanInterruptionResult(message, pendingExitPlanModeInterruption);
4565
+ const continuesPlan = exitPlanInterruption &&
4566
+ isHeldOpen(session.activeTurn) &&
4567
+ pendingExitPlanContextReset?.toolUseId ===
4568
+ pendingExitPlanModeInterruption?.toolUseId;
4569
+ if (!continuesPlan && !(isAutonomousResult && message.num_turns === 0)) {
4570
+ this.endAutonomousCycle(params.sessionId, autonomousCycleEnd(message, resultModelUsage, session.cancelled || exitPlanInterruption || isAbortedResult(message)));
4571
+ }
4446
4572
  if (session.cancelled) {
4447
4573
  session.pendingExitPlanModeInterruption = undefined;
4448
4574
  session.pendingExitPlanContextReset = undefined;
@@ -4450,6 +4576,21 @@ export class ClaudeAcpAgent {
4450
4576
  await clearFailuresFromEarlierTurns();
4451
4577
  stopReason = "cancelled";
4452
4578
  }
4579
+ // The cancelled turn ends at the result of the work the
4580
+ // interrupt stopped: its own result, or for a turn held open
4581
+ // for its subagents (see Turn.deferredSettle), its follow-up's
4582
+ // aborted one. A follow-up result that is not aborted, such as
4583
+ // a placeholder or one that ended before the interrupt landed,
4584
+ // belongs to other work. Claude Code ends the stopped work
4585
+ // first, then goes on with its queue, so the idle can come
4586
+ // much later, after a follow-up. That idle, the next echo's
4587
+ // hand-off, and the force-cancel backstop settle a turn whose
4588
+ // result never comes.
4589
+ const active = session.activeTurn;
4590
+ const stoppedWork = !isAutonomousResult || (isHeldOpen(active) && isAbortedResult(message));
4591
+ if (active && !active.settled && stoppedWork) {
4592
+ await settleActive(cancelledOutcome(session, active));
4593
+ }
4453
4594
  break;
4454
4595
  }
4455
4596
  // A held turn (see Turn.deferredSettle) settles at its
@@ -4486,8 +4627,11 @@ export class ClaudeAcpAgent {
4486
4627
  pendingExitPlanModeInterruption.toolUseId) {
4487
4628
  // The fresh query has none of this one's background
4488
4629
  // tasks, so the hold can never drain there: drop it, and
4489
- // let the continuation's own result settle the turn.
4630
+ // let the continuation's own result settle the turn. The
4631
+ // continuation is the turn's work again, which takes
4632
+ // over the follow-up cycle that planned it.
4490
4633
  heldTurn.deferredSettle = undefined;
4634
+ this.startForeground(params.sessionId, heldTurn);
4491
4635
  await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
4492
4636
  return;
4493
4637
  }
@@ -4754,9 +4898,10 @@ export class ClaudeAcpAgent {
4754
4898
  // subagent never holds the prompt hostage.
4755
4899
  //
4756
4900
  // is_error/auth already settled via failActive (activeTurn is null
4757
- // then, so both branches no-op); cancellation is left to the
4758
- // idle/abort path. settleActive is idempotent, so a duplicate
4759
- // idle is a no-op.
4901
+ // then, so both branches no-op); a cancelled turn settles at
4902
+ // the `session.cancelled` guard above, or on the idle/abort
4903
+ // path when its result never comes. settleActive is idempotent,
4904
+ // so a duplicate idle is a no-op.
4760
4905
  //
4761
4906
  // A result also closes this compaction lifecycle — before the
4762
4907
  // settle, so a `cancelled` terminal for an entity the runtime
@@ -4955,7 +5100,7 @@ export class ClaudeAcpAgent {
4955
5100
  if (session.activeTurn !== queued) {
4956
5101
  if (session.activeTurn) {
4957
5102
  // Hand off the previous turn. If a cancel is pending for it
4958
- // (its trailing idle hasn't arrived yet), settle it
5103
+ // (neither its result nor its idle has arrived), settle it
4959
5104
  // "cancelled" per the ACP contract rather than "end_turn" —
4960
5105
  // otherwise a cancel followed quickly by the next prompt
4961
5106
  // would report the cancelled turn as a normal completion.
@@ -5176,11 +5321,28 @@ export class ClaudeAcpAgent {
5176
5321
  }
5177
5322
  const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
5178
5323
  let backgroundedToolCalls = new Set();
5324
+ let runningTerminal;
5179
5325
  if (message.type === "user") {
5180
5326
  rememberResolvedToolNames(session, content);
5181
5327
  const backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
5182
5328
  if (backgroundBashTask)
5183
5329
  await asyncTasks.taskBackgrounded(backgroundBashTask);
5330
+ // The command's terminal runs until its task ends, unless the
5331
+ // task has ended already (a fast command can end before the tool
5332
+ // result comes).
5333
+ const terminalId = backgroundBashTask?.toolCallId;
5334
+ if (terminalId &&
5335
+ this.toolCallCapabilities.terminalOutput &&
5336
+ !this.toolCallCapabilities.air.client &&
5337
+ session.liveBackgroundTasks.has(backgroundBashTask.taskId)) {
5338
+ runningTerminal = terminalId;
5339
+ (session.backgroundTerminals ??= new Map()).set(backgroundBashTask.taskId, {
5340
+ toolCallId: terminalId,
5341
+ ...(message.parent_tool_use_id
5342
+ ? { parentToolUseId: message.parent_tool_use_id }
5343
+ : {}),
5344
+ });
5345
+ }
5184
5346
  await asyncTasks.toolResults(content);
5185
5347
  backgroundedToolCalls = backgroundedBashToolCallIds(content, session.toolUseCache, asyncTasks, backgroundBashTask);
5186
5348
  const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
@@ -5211,7 +5373,7 @@ export class ClaudeAcpAgent {
5211
5373
  // filtered out of `content` above; blocks that do pass through
5212
5374
  // (e.g. a subagent image) carry the stamped parentToolUseId
5213
5375
  // meta and are excluded there.
5214
- await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled));
5376
+ await sendUpdate(withRunningTerminal(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled), runningTerminal));
5215
5377
  }
5216
5378
  break;
5217
5379
  }
@@ -5433,6 +5595,16 @@ export class ClaudeAcpAgent {
5433
5595
  return;
5434
5596
  }
5435
5597
  session.cancelled = true;
5598
+ // Before anything awaits, so a model request that the consumer reads in
5599
+ // the meantime is already judged against these prompts.
5600
+ session.cancelledPrompts ??= new Set();
5601
+ for (const turn of session.turnQueue ?? []) {
5602
+ if (turn.settled)
5603
+ continue;
5604
+ session.cancelledPrompts.add(turn.promptUuid);
5605
+ for (const uuid of turn.steeredUuids ?? [])
5606
+ session.cancelledPrompts.add(uuid);
5607
+ }
5436
5608
  for (const turn of session.turnQueue ?? [])
5437
5609
  turn.localCommandAbort?.abort();
5438
5610
  session.pendingExitPlanModeInterruption = undefined;
@@ -5483,9 +5655,10 @@ export class ClaudeAcpAgent {
5483
5655
  // never decrements the count) to miss.
5484
5656
  const lifecycleLane = session.msgLifecycleV1 === true;
5485
5657
  // Settle queued turns that haven't started yet (no echo seen) right away —
5486
- // they have no in-flight SDK work to interrupt. The active turn is settled
5487
- // by the consumer when it observes the interrupt's trailing idle (or via the
5488
- // backstop below). Mirrors the old pendingMessages cancellation.
5658
+ // they have no in-flight SDK work to interrupt. The consumer settles the
5659
+ // active turn at its interrupted result, or at the idle when that never
5660
+ // comes (or via the backstop below). Mirrors the old pendingMessages
5661
+ // cancellation.
5489
5662
  const orphanedTurns = [];
5490
5663
  if (session.turnQueue) {
5491
5664
  for (const turn of session.turnQueue) {
@@ -5571,14 +5744,19 @@ export class ClaudeAcpAgent {
5571
5744
  // whose session sits idle gets no trailing idle (the interrupt emits
5572
5745
  // nothing; with no state events consumed yet, the same is assumed).
5573
5746
  // A held turn whose followup cycle is live (running, or blocked on a
5574
- // permission request) is left to that idle, like any live turn: the
5575
- // interrupt still flushes the cycle's output, which must reach the client
5576
- // before the turn ends cancelled. The captured outcome preserves usage and
5577
- // metadata (issue #844), while reporter state makes a late checkpoint
5578
- // response harmless.
5747
+ // permission request) is left to that cycle's interrupted result, like any
5748
+ // live turn, or to the idle when no result comes. The captured outcome
5749
+ // preserves usage and metadata (issue #844), while reporter state makes a
5750
+ // late checkpoint response harmless.
5751
+ // Since CLI 2.1.270, Claude Code reports `running` for as long as
5752
+ // background agents live, so the session state cannot tell a live
5753
+ // followup from a hold. With request markers, a followup is live when one
5754
+ // of its model requests started after the turn's result.
5579
5755
  {
5580
5756
  const active = session.activeTurn;
5581
- const followupLive = session.lastSessionState === "running" || session.lastSessionState === "requires_action";
5757
+ const followupLive = session.requestMarkerListsV1
5758
+ ? session.modelRequestSinceResult === true
5759
+ : session.lastSessionState === "running" || session.lastSessionState === "requires_action";
5582
5760
  if (active &&
5583
5761
  (active.settling || (isHeldOpen(active) && !followupLive)) &&
5584
5762
  (active.deferredSettle ?? active.settlingOutcome)) {
@@ -5618,7 +5796,7 @@ export class ClaudeAcpAgent {
5618
5796
  }
5619
5797
  }
5620
5798
  // A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
5621
- // consumer at the interrupt's trailing idle, like any live turn. But the
5799
+ // consumer at its interrupted result, or at the idle, like any live turn. But the
5622
5800
  // steer lane pays for its last result's trailer by settling on it, which
5623
5801
  // this cancel pre-empts, so count that trailer here or it arrives un-owed
5624
5802
  // against the next prompt and false-fails it (issue #825). Over-counting
@@ -5649,6 +5827,10 @@ export class ClaudeAcpAgent {
5649
5827
  }
5650
5828
  // Before the interrupt: once it stops the running turn, Claude Code starts
5651
5829
  // the next queued message right away.
5830
+ // A prompt or steer that arrived while this cancel awaited is abandoned
5831
+ // too, after the record above was taken.
5832
+ for (const uuid of abandoned)
5833
+ (session.cancelledPrompts ??= new Set()).add(uuid);
5652
5834
  this.dropQueuedMessages(params.sessionId, session.query, abandoned);
5653
5835
  const interrupt = session.query.interrupt();
5654
5836
  if (!options.awaitInterrupt) {
@@ -5769,6 +5951,9 @@ export class ClaudeAcpAgent {
5769
5951
  catch (error) {
5770
5952
  this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
5771
5953
  }
5954
+ // The turns end before this returns; so does a model cycle that Claude
5955
+ // Code started on its own, which no turn waits for.
5956
+ this.endAutonomousCycle(sessionId, { outcome: { stopReason: "cancelled" } });
5772
5957
  // cancel() arms the force-cancel floor and interrupts gracefully, but a
5773
5958
  // wedged consumer only wakes when `cancelController` aborts — closeQueryStream
5774
5959
  // below doesn't touch it. Since we're tearing the session down anyway, wake
@@ -6275,43 +6460,104 @@ export class ClaudeAcpAgent {
6275
6460
  const response = await this.client.writeTextFile(params);
6276
6461
  return response;
6277
6462
  }
6278
- /** Mark a client request as blocking on user input for exactly the lifetime
6279
- * of its promise. Steering consults this session-local count synchronously,
6280
- * so a message arriving while any permission/elicitation card is open uses
6281
- * non-interrupting SDK delivery. The active turn reports that it awaits the
6282
- * user while any such request is open (see `syncAwaitingUser`). */
6283
- async withPendingUserInput(sessionId, request) {
6463
+ /** Mark a client request of the subagent `agentID` (absent for the main
6464
+ * conversation) as awaiting user input for exactly the lifetime of its
6465
+ * promise (see `Session.openUserInputs`). Steering consults the open
6466
+ * requests synchronously, so a message arriving while any permission or
6467
+ * elicitation card is open uses non-interrupting SDK delivery. */
6468
+ async withPendingUserInput(sessionId, request, agentID) {
6284
6469
  const session = this.sessions[sessionId];
6285
6470
  if (!session)
6286
6471
  return request();
6287
- session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
6288
- this.syncAwaitingUser(session);
6472
+ const input = agentID !== undefined ? { agentID } : {};
6473
+ (session.openUserInputs ??= new Set()).add(input);
6474
+ this.syncAwaitingUser(sessionId, session);
6289
6475
  try {
6290
6476
  return await request();
6291
6477
  }
6292
6478
  finally {
6293
- session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
6294
- this.syncAwaitingUser(session);
6295
- }
6296
- }
6297
- /** Report whether the session's active turn awaits the user: exactly while
6298
- * a permission request or question is open in the session. A request does
6299
- * not say which prompt it is for, and Claude Code can ask for a queued
6300
- * prompt before the consumer activates it, so a request is not tied to the
6301
- * turn that was active when it opened. Called when the count changes and
6302
- * when a turn activates. */
6303
- syncAwaitingUser(session) {
6304
- const turn = session.activeTurn;
6305
- if (!turn || turn.settled || !turn.insertedReported)
6479
+ session.openUserInputs.delete(input);
6480
+ this.syncAwaitingUser(sessionId, session);
6481
+ }
6482
+ }
6483
+ /** Report whether the session's foreground work awaits the user: exactly
6484
+ * while a permission request or question that blocks it is open (see
6485
+ * {@link blocksForeground}). A request does not say which prompt it is for,
6486
+ * and Claude Code can ask for a queued prompt before the consumer activates
6487
+ * it, so a request is not tied to the work that was running when it
6488
+ * opened. Called when a request opens or closes, when a turn activates, and
6489
+ * when a task registers or moves to the background. */
6490
+ syncAwaitingUser(sessionId, session) {
6491
+ const work = this.foregroundWork.get(sessionId);
6492
+ if (!work)
6306
6493
  return;
6307
- const waiting = (session.pendingUserInputCount ?? 0) > 0;
6308
- if (waiting === (turn.awaitingUser ?? false))
6494
+ const waiting = [...(session.openUserInputs ?? [])].some((input) => blocksForeground(session, input.agentID));
6495
+ if (waiting === work.awaitingUser)
6309
6496
  return;
6310
- turn.awaitingUser = waiting;
6497
+ work.awaitingUser = waiting;
6311
6498
  if (waiting)
6312
- turn.events.awaitingUser();
6499
+ work.events.awaitingUser();
6500
+ else
6501
+ work.events.running();
6502
+ }
6503
+ /** Make `owner` the foreground work of the session `sessionId` (see
6504
+ * {@link ForegroundEvents}): it reports `running`, then waits at once if a
6505
+ * request is open. If work already runs, it goes on under `owner` and
6506
+ * reports nothing new: a turn takes over a cycle that Claude Code started on
6507
+ * its own, and only the owner's end ends the work. */
6508
+ startForeground(sessionId, owner) {
6509
+ if (!this.foregroundEvents)
6510
+ return;
6511
+ const current = this.foregroundWork.get(sessionId);
6512
+ if (current) {
6513
+ current.owner = owner;
6514
+ return;
6515
+ }
6516
+ const events = this.foregroundEvents(sessionId);
6517
+ this.foregroundWork.set(sessionId, { owner, events, awaitingUser: false });
6518
+ events.running();
6519
+ const session = this.sessions[sessionId];
6520
+ if (session)
6521
+ this.syncAwaitingUser(sessionId, session);
6522
+ }
6523
+ /** End the foreground work of the session `sessionId` with `outcome`, or as
6524
+ * failed with `error`, if `owner` is that work. */
6525
+ endForeground(sessionId, owner, end) {
6526
+ const work = this.foregroundWork.get(sessionId);
6527
+ if (work?.owner !== owner)
6528
+ return;
6529
+ this.foregroundWork.delete(sessionId);
6530
+ if ("outcome" in end)
6531
+ work.events.ended(end.outcome);
6313
6532
  else
6314
- turn.events.resumed();
6533
+ work.events.failed(end.error);
6534
+ }
6535
+ /** Start a model cycle that Claude Code started on its own as the foreground
6536
+ * work (see {@link ForegroundEvents}) when `message` is its first output:
6537
+ * model output of the main conversation (not a subagent's) while no work
6538
+ * runs. That includes a turn that v1 holds for its background subagents,
6539
+ * whose work ended at its result, so its follow-up is a cycle. Output of
6540
+ * the work a cancel stopped starts nothing (see Session.cancelled). */
6541
+ startAutonomousCycle(sessionId, session, message) {
6542
+ if (!this.foregroundEvents || this.foregroundWork.has(sessionId) || session.cancelled)
6543
+ return;
6544
+ if (message.type !== "assistant" && message.type !== "stream_event")
6545
+ return;
6546
+ if (message.parent_tool_use_id !== null)
6547
+ return;
6548
+ // A turn that Claude Code started is the foreground work already. Without
6549
+ // lifecycle frames, a queued turn's output can come before its echo, so
6550
+ // output while a turn is queued may be that turn's. With them, a turn that
6551
+ // Claude Code has not started has no output yet.
6552
+ const queued = (session.turnQueue ?? []).some((turn) => !turn.settled && turn !== session.activeTurn);
6553
+ if (queued && !session.msgLifecycleV1)
6554
+ return;
6555
+ this.startForeground(sessionId, AUTONOMOUS_CYCLE);
6556
+ }
6557
+ /** End the session's model cycle that Claude Code started on its own, if one
6558
+ * runs. */
6559
+ endAutonomousCycle(sessionId, end) {
6560
+ this.endForeground(sessionId, AUTONOMOUS_CYCLE, end);
6315
6561
  }
6316
6562
  /** Forward a permission request to the client, wiring the tool call's
6317
6563
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
@@ -6319,7 +6565,7 @@ export class ClaudeAcpAgent {
6319
6565
  * `$/cancel_request`, and our local abort race settles even if the client
6320
6566
  * ignores it. A `cancelled` outcome, request rejection, and local abort all
6321
6567
  * surface the same "Tool use aborted" the callers already expect. */
6322
- async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId, toolInput, previewContent) {
6568
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId, agentID, toolInput, previewContent) {
6323
6569
  if (signal.aborted)
6324
6570
  throw new Error("Tool use aborted");
6325
6571
  // The SDK may invoke `canUseTool` (and therefore this permission request)
@@ -6337,7 +6583,7 @@ export class ClaudeAcpAgent {
6337
6583
  // The request goes to a subagent's child session for a tool call of that
6338
6584
  // subagent, but it blocks the turn of the owner session.
6339
6585
  try {
6340
- return await this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
6586
+ return await this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID);
6341
6587
  }
6342
6588
  catch (error) {
6343
6589
  if (signal.aborted) {
@@ -6404,6 +6650,84 @@ export class ClaudeAcpAgent {
6404
6650
  throw error;
6405
6651
  }
6406
6652
  }
6653
+ /**
6654
+ * The owner of a tool use that Claude Code reports outside the stream, in
6655
+ * `canUseTool` or a hook, for the subagent `agentID` or the root session.
6656
+ * `parentToolUseId` is the Agent/Task tool call that spawned the subagent,
6657
+ * as the streamed subagent path stamps it in `_meta.claudeCode` (see
6658
+ * `liveBackgroundTasks`). `toolCallSessionId` is the session of an eagerly
6659
+ * emitted tool call and of its requests: the subagent's child session once
6660
+ * it is announced, else the root session. In the rare SDK ordering where
6661
+ * the callback beats the spawning Agent/Task frame, the root keeps it; the
6662
+ * later frame announces the child with its correct nested lineage.
6663
+ */
6664
+ toolUseOwner(session, sessionId, agentID) {
6665
+ const parentToolUseId = agentID
6666
+ ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
6667
+ : undefined;
6668
+ const child = clientSupportsSubagents(this.clientCapabilities) && agentID
6669
+ ? session.nativeSubagentsByTaskId?.get(agentID)
6670
+ : undefined;
6671
+ return {
6672
+ ...(parentToolUseId ? { parentToolUseId } : {}),
6673
+ toolCallSessionId: child?.announced ? child.sessionId : sessionId,
6674
+ };
6675
+ }
6676
+ /**
6677
+ * The PreToolUse hook of a v2 connection, for Edit and Write: the tool call
6678
+ * shows the exact patch of the change before the tool runs.
6679
+ *
6680
+ * A v2 diff cannot hold the input's snippet, so without it an auto-approved
6681
+ * Edit or Write would show its change only once it is done. Claude Code runs
6682
+ * the tool only after the hook returns, so the file is as it was before the
6683
+ * change, as for the approval patch of a permission request (which runs
6684
+ * after this hook, and shows the same patch). The hook makes no permission
6685
+ * decision, and a failure only leaves the preview out.
6686
+ */
6687
+ fileChangePreviewHook(sessionId) {
6688
+ return async (input, toolUseID) => {
6689
+ if (input.hook_event_name === "PreToolUse" && toolUseID) {
6690
+ await this.previewFileChange(sessionId, input, toolUseID).catch((error) => this.logger.error(`Session ${sessionId}: failed to preview ${toolUseID}: ${error}`));
6691
+ }
6692
+ return { continue: true };
6693
+ };
6694
+ }
6695
+ async previewFileChange(sessionId, input, toolUseID) {
6696
+ const session = this.sessions[sessionId];
6697
+ if (!session)
6698
+ return;
6699
+ const toolInput = (input.tool_input ?? {});
6700
+ const content = await previewPatchContent(input.tool_name, toolInput, session.cwd, "v2");
6701
+ if (!content)
6702
+ return;
6703
+ const { parentToolUseId, toolCallSessionId } = this.toolUseOwner(session, sessionId, input.agent_id);
6704
+ // Hook callbacks bypass the stream, so the hook can come before the
6705
+ // streamed tool_use, as `canUseTool` can: then the tool call goes out
6706
+ // with the patch.
6707
+ if (!session.emittedToolCalls.has(toolUseID)) {
6708
+ await this.ensureToolCallEmitted(sessionId, input.tool_name, toolUseID, toolInput, parentToolUseId, undefined, toolCallSessionId, content);
6709
+ return;
6710
+ }
6711
+ const update = {
6712
+ sessionUpdate: "tool_call_update",
6713
+ toolCallId: toolUseID,
6714
+ content,
6715
+ };
6716
+ if (parentToolUseId)
6717
+ stampParentToolUseId(update, parentToolUseId);
6718
+ const fields = toolCallFieldsOf(session);
6719
+ if (!fields.apply(update))
6720
+ return;
6721
+ // The streamed input must not replace the patch, as for an approval patch.
6722
+ fields.pinContent(toolUseID, content);
6723
+ // The stream's router sends it to the session that holds the tool call.
6724
+ if (session.nativeSubagentDeliver) {
6725
+ await session.nativeSubagentDeliver({ sessionId, update });
6726
+ }
6727
+ else {
6728
+ await this.client.sessionUpdate({ sessionId: toolCallSessionId, update });
6729
+ }
6730
+ }
6407
6731
  canUseTool(sessionId) {
6408
6732
  return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, defaultToNo, suppressAlwaysAllowRule, mcpServer, }) => {
6409
6733
  const session = this.sessions[sessionId];
@@ -6413,24 +6737,7 @@ export class ClaudeAcpAgent {
6413
6737
  message: "Session not found",
6414
6738
  };
6415
6739
  }
6416
- // When the tool call originates inside a subagent, attribute the eagerly
6417
- // emitted tool_call (and the permission request itself) to the Agent/Task
6418
- // tool call that spawned the subagent, mirroring the streamed subagent
6419
- // path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
6420
- const parentToolUseId = agentID
6421
- ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
6422
- : undefined;
6423
- const permissionSessionId = clientSupportsSubagents(this.clientCapabilities) && agentID
6424
- ? (() => {
6425
- const child = session.nativeSubagentsByTaskId?.get(agentID);
6426
- // A request must never target a child session before its
6427
- // subagent_spawned notification. In the rare SDK ordering where
6428
- // canUseTool beats the spawning Agent/Task frame, keep the
6429
- // permission on the root session; the later frame will announce
6430
- // the child with correct nested lineage.
6431
- return child?.announced ? child.sessionId : sessionId;
6432
- })()
6433
- : sessionId;
6740
+ const { parentToolUseId, toolCallSessionId: permissionSessionId } = this.toolUseOwner(session, sessionId, agentID);
6434
6741
  if (agentID && !parentToolUseId) {
6435
6742
  // The attribution rests on task_started.task_id === canUseTool's
6436
6743
  // agentID for subagent tasks (documented since SDK 0.3.292, which
@@ -6449,7 +6756,7 @@ export class ClaudeAcpAgent {
6449
6756
  // Like permission requests, the elicitation references this toolUseID, so
6450
6757
  // make sure the tool_call has surfaced to the client before we send it.
6451
6758
  await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal, permissionSessionId);
6452
- return this.handleAskUserQuestion(sessionId, permissionSessionId, toolInput, toolUseID, signal);
6759
+ return this.handleAskUserQuestion(sessionId, permissionSessionId, toolInput, toolUseID, signal, agentID);
6453
6760
  }
6454
6761
  // Do not auto-allow here based on the session's advertised mode. Claude
6455
6762
  // Code applies bypassPermissions before invoking canUseTool; a request
@@ -6518,7 +6825,7 @@ export class ClaudeAcpAgent {
6518
6825
  ...presentation,
6519
6826
  options: permissionOptions,
6520
6827
  sessionId: permissionSessionId,
6521
- }, toolName, signal, parentToolUseId, sessionId, toolInput, previewContent);
6828
+ }, toolName, signal, parentToolUseId, sessionId, agentID, toolInput, previewContent);
6522
6829
  if (signal.aborted)
6523
6830
  throw new Error("Tool use aborted");
6524
6831
  const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
@@ -6606,7 +6913,7 @@ export class ClaudeAcpAgent {
6606
6913
  * The elicitation goes to `requestSessionId`, a subagent's child session for
6607
6914
  * a question it asks, and blocks the turn of `sessionId`.
6608
6915
  */
6609
- async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, signal) {
6916
+ async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, signal, agentID) {
6610
6917
  const questions = extractAskUserQuestions(toolInput);
6611
6918
  if (!questions) {
6612
6919
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
@@ -6614,7 +6921,7 @@ export class ClaudeAcpAgent {
6614
6921
  const createRequest = askUserQuestionsToCreateRequest(questions, requestSessionId, toolUseID, this.toolCallCapabilities.air.client);
6615
6922
  let response;
6616
6923
  try {
6617
- response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
6924
+ response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal), agentID);
6618
6925
  }
6619
6926
  catch (error) {
6620
6927
  // A cancellation we requested (signal aborted) settles as an aborted tool
@@ -7277,28 +7584,23 @@ export class ClaudeAcpAgent {
7277
7584
  // into the query.
7278
7585
  const resolvedProvider = this.resolveProviderConfig();
7279
7586
  const providerEnv = createEnvForProvider(resolvedProvider);
7280
- const configuredSettings = userProvidedOptions?.settings ??
7281
- (modelConfig
7282
- ? {
7283
- ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
7284
- ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
7285
- }
7286
- : undefined);
7287
- const configuredSettingsObject = typeof configuredSettings === "string"
7288
- ? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
7289
- : configuredSettings;
7587
+ const providedSettings = userProvidedOptions?.settings;
7588
+ const providedSettingsObject = typeof providedSettings === "string"
7589
+ ? JSON.parse(await fs.readFile(path.resolve(params.cwd, providedSettings), "utf8"))
7590
+ : providedSettings;
7591
+ const configuredSettingsObject = modelConfig
7592
+ ? { ...modelConfig, ...providedSettingsObject }
7593
+ : providedSettingsObject;
7290
7594
  // Claude Code applies env from settings.json after the subprocess env. Put
7291
7595
  // an active ACP route in the programmatic settings tier too so user/project
7292
7596
  // settings cannot silently restore a different ANTHROPIC_BASE_URL.
7293
- let settings = configuredSettings;
7294
- if (resolvedProvider) {
7295
- const baseSettings = configuredSettingsObject;
7296
- settings = {
7297
- ...baseSettings,
7597
+ const settings = resolvedProvider
7598
+ ? {
7599
+ ...configuredSettingsObject,
7298
7600
  apiKeyHelper: "",
7299
- env: { ...baseSettings?.env, ...providerEnv },
7300
- };
7301
- }
7601
+ env: { ...configuredSettingsObject?.env, ...providerEnv },
7602
+ }
7603
+ : configuredSettingsObject;
7302
7604
  const env = {
7303
7605
  ...process.env,
7304
7606
  ...userProvidedOptions?.env,
@@ -7365,6 +7667,17 @@ export class ClaudeAcpAgent {
7365
7667
  tools,
7366
7668
  hooks: {
7367
7669
  ...userProvidedOptions?.hooks,
7670
+ // On v2, an Edit or Write shows its exact patch before it runs, also
7671
+ // when no permission request shows it (`fileChangePreviewHook`): a v2
7672
+ // diff cannot hold the input's snippet.
7673
+ ...(this.v2
7674
+ ? {
7675
+ PreToolUse: [
7676
+ ...(userProvidedOptions?.hooks?.PreToolUse || []),
7677
+ { matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] },
7678
+ ],
7679
+ }
7680
+ : {}),
7368
7681
  PostToolUse: [
7369
7682
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
7370
7683
  {
@@ -7853,6 +8166,30 @@ function cancelledOutcome(session, turn) {
7853
8166
  ...(recorded._meta ? { _meta: recorded._meta } : {}),
7854
8167
  };
7855
8168
  }
8169
+ /** Whether the model request that `status` (a `status: requesting` frame)
8170
+ * starts may belong to the work a cancel stopped. It may while:
8171
+ * - a turn is unsettled: the stopped turn, whose result is still to come, or
8172
+ * a queued prompt, which the cancel abandons (one that arrived while the
8173
+ * cancel awaited is recorded only when the cancel sweeps the queue) or
8174
+ * whose echo ends the cancel right after its request anyway;
8175
+ * - the orphan accounting still expects the result of an abandoned prompt
8176
+ * that Claude Code may still run, which it can fold into its own
8177
+ * follow-up after that follow-up's first request;
8178
+ * - the request serves one of {@link Session.cancelledPrompts}, as an
8179
+ * abandoned prompt's request does even when the interrupt receipt left it
8180
+ * out of the orphan accounting. */
8181
+ function servesCancelledWork(session, status) {
8182
+ const cancelled = session.cancelledPrompts;
8183
+ if (!cancelled)
8184
+ return false;
8185
+ if ((session.turnQueue ?? []).some((turn) => !turn.settled))
8186
+ return true;
8187
+ if ((session.pendingOrphanResults ?? 0) > 0 || (session.orphanCommands?.size ?? 0) > 0) {
8188
+ return true;
8189
+ }
8190
+ const served = status.user_message_uuids;
8191
+ return (Array.isArray(served) && served.some((uuid) => typeof uuid === "string" && cancelled.has(uuid)));
8192
+ }
7856
8193
  /** `_meta.quota` for a prompt response: what the turn spent, shaped like
7857
8194
  * codex-acp's (snake_case container keys, camelCase counters) so a client
7858
8195
  * reads one shape from either agent.
@@ -7865,16 +8202,88 @@ function cancelledOutcome(session, turn) {
7865
8202
  * so the rows can total more than `token_count`. They are the fuller picture,
7866
8203
  * not a decomposition of it. */
7867
8204
  function turnQuotaMeta(session) {
8205
+ return quotaMeta(session.accumulatedUsage, session.accumulatedModelUsage);
8206
+ }
8207
+ /** `_meta.quota` for work that spent `usage` in its main agent loop and
8208
+ * `modelUsage` by model (see {@link turnQuotaMeta}). */
8209
+ function quotaMeta(usage, modelUsage) {
7868
8210
  return {
7869
8211
  quota: {
7870
- token_count: quotaTokenCount(session.accumulatedUsage),
7871
- model_usage: Object.entries(session.accumulatedModelUsage ?? {}).map(([model, usage]) => ({
8212
+ token_count: quotaTokenCount(usage),
8213
+ model_usage: Object.entries(modelUsage ?? {}).map(([model, usage]) => ({
7872
8214
  model,
7873
8215
  token_count: quotaTokenCount(usage),
7874
8216
  })),
7875
8217
  },
7876
8218
  };
7877
8219
  }
8220
+ /** Whether a request that awaits the user for the subagent `agentID` (absent
8221
+ * for the main conversation) blocks the session's foreground work. The main
8222
+ * conversation's requests do. So do those of a subagent that runs in the
8223
+ * foreground of the main conversation, since the tool call that spawned it
8224
+ * waits on it, and so on up a chain of such subagents. A request of a
8225
+ * subagent in the background, or nested inside one, does not: the main
8226
+ * conversation goes on without it. Nor does one of an agent without a
8227
+ * record: a teammate, a settled subagent, or one that Claude Code left out
8228
+ * of its background tasks and the consumer pruned, which only happens to a
8229
+ * subagent nested in a background one. A subagent that the main
8230
+ * conversation waits on has a record by the time it asks, as its
8231
+ * `task_started` comes first; when the consumer reads the request first, the
8232
+ * `task_started` checks again. */
8233
+ function blocksForeground(session, agentID) {
8234
+ const seen = new Set();
8235
+ for (let id = agentID; id !== undefined && !seen.has(id);) {
8236
+ seen.add(id);
8237
+ const task = session.liveBackgroundTasks.get(id);
8238
+ if (!task)
8239
+ return false;
8240
+ if (task.backgrounded)
8241
+ return false;
8242
+ id = task.parentTaskId;
8243
+ }
8244
+ return true;
8245
+ }
8246
+ /** Whether Claude Code ended the work of `result` because an interrupt
8247
+ * stopped it, while the model streamed or while a tool ran. */
8248
+ function isAbortedResult(result) {
8249
+ return (result.terminal_reason === "aborted_streaming" || result.terminal_reason === "aborted_tools");
8250
+ }
8251
+ /** How a model cycle that Claude Code started on its own ended, read from its
8252
+ * result as a turn's is: a refusal, the token limit, and the turn and budget
8253
+ * limits are their stop reasons, an error result fails the cycle, and a
8254
+ * cancel ends it `cancelled`. The usage and `_meta.quota` are the result's
8255
+ * own, `modelUsage` its increment by model, since autonomous results stay out
8256
+ * of the turn tally. */
8257
+ function autonomousCycleEnd(result, modelUsage, cancelled) {
8258
+ // Typed non-null, but third-party backends have been observed omitting
8259
+ // usage token fields (see snapshotFromUsage).
8260
+ const tally = {
8261
+ inputTokens: result.usage.input_tokens ?? 0,
8262
+ outputTokens: result.usage.output_tokens ?? 0,
8263
+ cachedReadTokens: result.usage.cache_read_input_tokens ?? 0,
8264
+ cachedWriteTokens: result.usage.cache_creation_input_tokens ?? 0,
8265
+ };
8266
+ const ended = (stopReason) => ({
8267
+ outcome: {
8268
+ stopReason,
8269
+ usage: { ...tally, totalTokens: tallyTotal(tally) },
8270
+ _meta: quotaMeta(tally, modelUsage),
8271
+ },
8272
+ });
8273
+ if (cancelled)
8274
+ return ended("cancelled");
8275
+ if (result.stop_reason === "refusal")
8276
+ return ended("refusal");
8277
+ if (result.stop_reason === "max_tokens")
8278
+ return ended("max_tokens");
8279
+ if (result.is_error) {
8280
+ const text = result.subtype === "success" ? result.result : result.errors.join(", ") || result.subtype;
8281
+ return { error: RequestError.internalError(undefined, text) };
8282
+ }
8283
+ return ended(result.subtype === "success" || result.subtype === "error_during_execution"
8284
+ ? "end_turn"
8285
+ : "max_turn_requests");
8286
+ }
7878
8287
  /** One `token_count` object. `cachedInputTokens` is cache reads, matching
7879
8288
  * codex's field; Claude also reports cache writes, which codex has no slot
7880
8289
  * for, so those ride along in an extra sibling (the same name the ACP `usage`