@agentclientprotocol/claude-agent-acp 0.66.0 → 0.68.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.
package/dist/acp-agent.js CHANGED
@@ -3,6 +3,7 @@ import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query,
3
3
  import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
4
4
  import { execFile } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { existsSync } from "node:fs";
6
7
  import * as fs from "node:fs/promises";
7
8
  import * as os from "node:os";
8
9
  import * as path from "node:path";
@@ -10,11 +11,13 @@ import { promisify } from "node:util";
10
11
  import packageJson from "../package.json" with { type: "json" };
11
12
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
12
13
  import { SettingsManager } from "./settings.js";
13
- import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
14
+ import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
15
+ import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
14
16
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
15
17
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
16
18
  const execFileAsync = promisify(execFile);
17
19
  const MAX_TITLE_LENGTH = 256;
20
+ const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
18
21
  function sanitizeTitle(text) {
19
22
  // Replace newlines and collapse whitespace
20
23
  const sanitized = text
@@ -100,11 +103,13 @@ function parseSteerRequest(params) {
100
103
  * result is the turn's real terminal.
101
104
  *
102
105
  * Deliberately fail-OPEN: an unknown future kind defaults to the user
103
- * lane. Misrouting a USER result into the autonomous lane hangs the
104
- * prompt un-detectably (the result is skipped, its trailing idle absorbed
105
- * as owed, so the #825 detector can't fire); misrouting an autonomous
106
- * result into the user lane is the bounded misattribution class this set
107
- * exists to reduce. */
106
+ * lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
107
+ * attribute this" marker, which gets the same safe default. Misrouting a
108
+ * USER result into the autonomous lane hangs the prompt un-detectably
109
+ * (the result is skipped, its trailing idle absorbed as owed, so the
110
+ * #825 detector can't fire); misrouting an autonomous result into the
111
+ * user lane is the bounded misattribution class this set exists to
112
+ * reduce. */
108
113
  const AUTONOMOUS_RESULT_ORIGINS = new Set([
109
114
  "task-notification",
110
115
  "peer",
@@ -143,17 +148,9 @@ function computeSessionFingerprint(params) {
143
148
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
144
149
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
145
150
  }
146
- /**
147
- * The single provider ID this agent exposes via `providers/*`. Claude Code has
148
- * one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
149
- * is exactly one configurable provider.
150
- */
151
- const PROVIDER_ID = "main";
152
- /**
153
- * Protocols the `main` provider can be configured with. These mirror the
154
- * env-var mappings understood by {@link createEnvForProvider}.
155
- */
156
151
  const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
152
+ const PROVIDER_ID = "main";
153
+ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
157
154
  const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
158
155
  function supportsSubagentTranscript(capabilities) {
159
156
  return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
@@ -578,9 +575,7 @@ export class ClaudeAcpAgent {
578
575
  clientCapabilities;
579
576
  logger;
580
577
  gatewayAuthRequest;
581
- /** Client-managed LLM routing set via `providers/set`. Process-scoped and
582
- * never persisted to disk (see the Configurable LLM Providers RFD). When
583
- * set, it takes precedence over {@link gatewayAuthRequest}. */
578
+ /** Set while ACP overrides the agent's native provider configuration. */
584
579
  providerConfig;
585
580
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
586
581
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
@@ -734,6 +729,7 @@ export class ClaudeAcpAgent {
734
729
  // steering extension contract: advertises the `_session/steering` request
735
730
  // so clients know they may inject a follow-up into a running turn.
736
731
  _meta: {
732
+ ...airSessionFailureCapabilityMeta(),
737
733
  steering: {
738
734
  supported: true,
739
735
  },
@@ -847,21 +843,13 @@ export class ClaudeAcpAgent {
847
843
  }
848
844
  throw new Error("Method not implemented.");
849
845
  }
850
- /**
851
- * `providers/list` — returns the single client-configurable custom gateway
852
- * provider (`main`). `current` carries only non-secret routing (never headers,
853
- * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
854
- * display, and is `null` when the provider is not configured/disabled. The
855
- * provider is optional (`required: false`): while disabled/unconfigured the
856
- * agent falls back to its own default routing (normal Claude login).
857
- */
858
846
  async unstable_listProviders(_params) {
859
- const config = this.resolveProviderConfig();
847
+ const config = this.providerConfig ?? this.defaultProviderConfig();
860
848
  const provider = {
861
849
  providerId: PROVIDER_ID,
862
850
  supported: SUPPORTED_PROTOCOLS,
863
851
  required: false,
864
- current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
852
+ current: { apiType: config.apiType, baseUrl: config.baseUrl },
865
853
  };
866
854
  return { providers: [provider] };
867
855
  }
@@ -903,30 +891,43 @@ export class ClaudeAcpAgent {
903
891
  return {};
904
892
  }
905
893
  /**
906
- * `providers/disable` — disabling the `main` provider clears any client-managed
907
- * routing (both a `providers/set` config and the legacy gateway auth request),
908
- * so the agent reverts to its own default routing and `providers/list` reports
909
- * `current: null`. Disabling any other (unknown) ID is treated as a successful
910
- * no-op per the RFD's idempotency rule.
894
+ * `providers/disable` ends ACP ownership of the single mutually exclusive
895
+ * backend slot and restores the agent's native routing state.
911
896
  */
912
897
  async unstable_disableProvider(params) {
913
898
  if (params.providerId === PROVIDER_ID) {
914
899
  this.providerConfig = undefined;
915
- this.gatewayAuthRequest = undefined;
916
900
  }
917
901
  // Unknown provider: idempotent success.
918
902
  return {};
919
903
  }
920
- /**
921
- * Resolve the effective client-managed routing config. `providers/set` takes
922
- * precedence; otherwise fall back to the legacy gateway auth request. Returns
923
- * `null` when neither is configured.
924
- */
925
904
  resolveProviderConfig() {
926
- if (this.providerConfig) {
927
- return this.providerConfig;
905
+ return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
906
+ }
907
+ defaultProviderConfig() {
908
+ const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
909
+ if (gatewayConfig) {
910
+ return gatewayConfig;
911
+ }
912
+ if (process.env.CLAUDE_CODE_USE_BEDROCK) {
913
+ return {
914
+ apiType: "bedrock",
915
+ baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
916
+ headers: {},
917
+ };
928
918
  }
929
- return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
919
+ if (process.env.CLAUDE_CODE_USE_VERTEX) {
920
+ return {
921
+ apiType: "vertex",
922
+ baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
923
+ headers: {},
924
+ };
925
+ }
926
+ return {
927
+ apiType: "anthropic",
928
+ baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
929
+ headers: {},
930
+ };
930
931
  }
931
932
  async logout(_params) {
932
933
  // Clear in-memory gateway credentials supplied via `authenticate` and any
@@ -966,6 +967,9 @@ export class ClaudeAcpAgent {
966
967
  if (session.queryClosed) {
967
968
  throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
968
969
  }
970
+ if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
971
+ await this.publishTaskPlan(params.sessionId, session.taskState);
972
+ }
969
973
  const userMessage = promptToClaude(params);
970
974
  const promptUuid = randomUUID();
971
975
  userMessage.uuid = promptUuid;
@@ -1021,6 +1025,15 @@ export class ClaudeAcpAgent {
1021
1025
  },
1022
1026
  });
1023
1027
  }
1028
+ async publishTaskPlan(sessionId, taskState) {
1029
+ await this.client.sessionUpdate({
1030
+ sessionId,
1031
+ update: {
1032
+ sessionUpdate: "plan",
1033
+ entries: taskStateToPlanEntries(taskState),
1034
+ },
1035
+ });
1036
+ }
1024
1037
  async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
1025
1038
  const goalUpdate = goalUpdateFromPrompt(prompt);
1026
1039
  if (goalUpdate !== undefined) {
@@ -1168,6 +1181,8 @@ export class ClaudeAcpAgent {
1168
1181
  // it here so the subsequent `RequestError.internalError` can forward it to
1169
1182
  // clients as structured `data`, sparing them from pattern-matching on text.
1170
1183
  let lastAssistantError;
1184
+ let lastAssistantWasUsageLimit = false;
1185
+ let lastAssistantFailureTitle;
1171
1186
  // When a streaming classifier refuses a turn, the assistant message carries
1172
1187
  // stop_reason "refusal" and structured stop_details. We capture the
1173
1188
  // human-readable explanation so the terminal `result` can surface it.
@@ -1221,11 +1236,50 @@ export class ClaudeAcpAgent {
1221
1236
  }
1222
1237
  await this.client.sessionUpdate(notification);
1223
1238
  };
1239
+ let pendingWorkerShutdown = false;
1240
+ const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
1241
+ const sessionFailures = new SessionFailureController({
1242
+ sessionId: params.sessionId,
1243
+ state: session.sessionFailureState,
1244
+ capabilities: this.clientCapabilities,
1245
+ isCurrent: isCurrentConsumer,
1246
+ sendUpdate,
1247
+ logger: this.logger,
1248
+ });
1249
+ const createSessionFailure = async (kind, options = {}) => {
1250
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1251
+ return sessionFailures.prepare(kind, {
1252
+ turnId,
1253
+ sessionScoped: options.turnScoped === false,
1254
+ title: options.title,
1255
+ details: options.details,
1256
+ severity: options.severity,
1257
+ });
1258
+ };
1259
+ const publishSessionFailure = async (kind, options = {}) => {
1260
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1261
+ await sessionFailures.publish(kind, {
1262
+ turnId,
1263
+ sessionScoped: options.turnScoped === false,
1264
+ title: options.title,
1265
+ details: options.details,
1266
+ severity: options.severity,
1267
+ });
1268
+ };
1269
+ const clearFailuresFromEarlierTurns = async () => {
1270
+ const activeTurnId = session.activeTurn?.promptUuid;
1271
+ // Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
1272
+ // They are session-scoped and stay until superseded or dismissed by the user.
1273
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
1274
+ };
1275
+ const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
1224
1276
  const resetTurnScratch = () => {
1225
1277
  lastAssistantTotalUsage = null;
1226
1278
  lastAssistantUsage = null;
1227
1279
  lastAssistantModel = null;
1228
1280
  lastAssistantError = undefined;
1281
+ lastAssistantWasUsageLimit = false;
1282
+ lastAssistantFailureTitle = undefined;
1229
1283
  lastRefusalExplanation = null;
1230
1284
  compactionInProgress = false;
1231
1285
  // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
@@ -1504,6 +1558,7 @@ export class ClaudeAcpAgent {
1504
1558
  disarmForceCancel(session);
1505
1559
  const turn = session.activeTurn;
1506
1560
  if (!turn || turn.settled) {
1561
+ this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
1507
1562
  return;
1508
1563
  }
1509
1564
  turn.settled = true;
@@ -1517,6 +1572,31 @@ export class ClaudeAcpAgent {
1517
1572
  session.emittedAssistantText = false;
1518
1573
  turn.reject(error);
1519
1574
  };
1575
+ /** Complete a negotiated terminal failure on the prompt response itself,
1576
+ * which is the canonical AIR carrier. Legacy clients keep the historical
1577
+ * JSON-RPC rejection path. */
1578
+ const failActiveWithSessionFailure = async (kind, error, title) => {
1579
+ if (!supportsAirSessionFailures(this.clientCapabilities)) {
1580
+ failActive(error);
1581
+ return;
1582
+ }
1583
+ if (!session.activeTurn || session.activeTurn.settled) {
1584
+ this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
1585
+ await publishSessionFailure(kind, { turnScoped: false, title });
1586
+ return;
1587
+ }
1588
+ const failure = await createSessionFailure(kind, { title });
1589
+ if (!failure) {
1590
+ failActive(error);
1591
+ return;
1592
+ }
1593
+ sessionFailures.recordActive(failure);
1594
+ settleActive({
1595
+ stopReason: "end_turn",
1596
+ usage: sessionUsage(session),
1597
+ _meta: sessionFailureMeta(failure),
1598
+ });
1599
+ };
1520
1600
  /** Reject every in-flight turn — used when the stream dies. */
1521
1601
  const failAllTurns = (error) => {
1522
1602
  disarmForceCancel(session);
@@ -1634,6 +1714,24 @@ export class ClaudeAcpAgent {
1634
1714
  pendingNext = null;
1635
1715
  const { value: message, done } = raced.result;
1636
1716
  if (done || !message) {
1717
+ if (pendingWorkerShutdown) {
1718
+ pendingWorkerShutdown = false;
1719
+ if (session.activeTurn) {
1720
+ if (!isHeldOpen(session.activeTurn)) {
1721
+ await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
1722
+ }
1723
+ else {
1724
+ // The held turn already has its authoritative terminal outcome,
1725
+ // but EOF permanently closes the non-revivable Query. Preserve
1726
+ // the turn result below and report the independent session-health
1727
+ // failure without a turnId so AIR can offer a new session.
1728
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1729
+ }
1730
+ }
1731
+ else {
1732
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1733
+ }
1734
+ }
1637
1735
  // The stream ended. Settle the in-flight turns FIRST, then release the
1638
1736
  // stream resources — same order as the error paths (failAllTurns before
1639
1737
  // closeQueryStream). Settling is the user-facing contract; resource
@@ -1785,6 +1883,23 @@ export class ClaudeAcpAgent {
1785
1883
  // updated Fast mode state; reconcile it with what we seeded at
1786
1884
  // session creation.
1787
1885
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
1886
+ // Terminal-bound slash commands (absent when none, and on
1887
+ // older CLIs). The session/new advertisement runs before any
1888
+ // init frame can be observed, so the first latch (or a
1889
+ // genuine change) re-publishes the now-filtered list.
1890
+ if (message.terminal_slash_commands &&
1891
+ JSON.stringify(message.terminal_slash_commands) !==
1892
+ JSON.stringify(session.terminalSlashCommands)) {
1893
+ session.terminalSlashCommands = message.terminal_slash_commands;
1894
+ try {
1895
+ await this.sendAvailableCommandsUpdate(message.session_id);
1896
+ }
1897
+ catch (error) {
1898
+ // Advisory reconcile only — the client keeps its current
1899
+ // (unfiltered) list; never fail the turn over it.
1900
+ this.logger.error(`Failed to re-advertise slash commands: ${error}`);
1901
+ }
1902
+ }
1788
1903
  break;
1789
1904
  case "status": {
1790
1905
  // These banners count as delivered text (via sendUpdate), so
@@ -1977,7 +2092,7 @@ export class ClaudeAcpAgent {
1977
2092
  // the next prompt; only a timer could tell those apart.
1978
2093
  this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
1979
2094
  `for the active turn; failing the in-flight prompt (issue #825)`);
1980
- failActive(RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
2095
+ await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
1981
2096
  }
1982
2097
  // The SDK generates the session title in a background task and
1983
2098
  // persists it to the session file; `idle` is the turn-over
@@ -2035,7 +2150,7 @@ export class ClaudeAcpAgent {
2035
2150
  sessionId: message.session_id,
2036
2151
  update: {
2037
2152
  sessionUpdate: "available_commands_update",
2038
- availableCommands: getAvailableSlashCommands(message.commands),
2153
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
2039
2154
  },
2040
2155
  });
2041
2156
  break;
@@ -2177,9 +2292,14 @@ export class ClaudeAcpAgent {
2177
2292
  }
2178
2293
  break;
2179
2294
  case "worker_shutting_down":
2180
- // A Remote Control worker announced a graceful teardown. This is a
2181
- // live-tail signal for remote clients to explain why a session went
2182
- // away; it's not meaningful for a local stdio ACP session.
2295
+ // Defer until stream end. The announcement is durable and may be
2296
+ // replayed before later frames, but those frames do not prove that
2297
+ // a new worker epoch began: they can be buffered output from the
2298
+ // shutting-down worker. Keep the signal armed until the transport
2299
+ // actually ends. Deliberately do not add a quiet-period timer: the
2300
+ // iterator has no replay/live boundary, so a timeout could publish
2301
+ // while a slow replay is still in flight.
2302
+ pendingWorkerShutdown = true;
2183
2303
  break;
2184
2304
  case "elicitation_complete": {
2185
2305
  // A url-mode MCP elicitation finished server-side. Let the client
@@ -2199,10 +2319,21 @@ export class ClaudeAcpAgent {
2199
2319
  }
2200
2320
  case "plugin_install":
2201
2321
  case "notification":
2202
- case "api_retry":
2203
2322
  case "thinking_tokens":
2204
2323
  // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
2205
2324
  break;
2325
+ case "api_retry": {
2326
+ const title = message.error_status === null
2327
+ ? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
2328
+ : `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
2329
+ await publishSessionFailure(message.error_status === null
2330
+ ? "transport_lost"
2331
+ : providerFailureCategory(message.error), {
2332
+ title,
2333
+ severity: "warning",
2334
+ });
2335
+ break;
2336
+ }
2206
2337
  case "model_refusal_fallback": {
2207
2338
  // The SDK retried a refused turn on the fallback model and made
2208
2339
  // the swap persistent for the session. Without a notice the
@@ -2216,26 +2347,53 @@ export class ClaudeAcpAgent {
2216
2347
  // CLIs, where "revert" marked a turn-only fallback — for that
2217
2348
  // direction the session stays on the original model, so skip
2218
2349
  // the persistent-swap claim and the state sync.
2219
- const persistent = message.direction !== "revert";
2350
+ //
2351
+ // `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
2352
+ // "local" means a subagent / side-question / background fork
2353
+ // response fell back and the session model is unchanged, so
2354
+ // syncing the picker would advertise a model the session
2355
+ // isn't running. Absent scope means an older CLI, where every
2356
+ // retry was a session-level swap — treat as "session".
2357
+ const local = message.scope === "local";
2358
+ const persistent = message.direction !== "revert" && !local;
2220
2359
  const category = message.api_refusal_category
2221
2360
  ? ` (${message.api_refusal_category})`
2222
2361
  : "";
2223
- const explanation = message.api_refusal_explanation
2224
- ? `\n\n${message.api_refusal_explanation}`
2225
- : "";
2226
2362
  const outcome = persistent
2227
2363
  ? `The session will continue on ${message.fallback_model}.`
2228
- : `The session stays on ${message.original_model}.`;
2229
- await sendUpdate({
2230
- sessionId: message.session_id,
2231
- update: {
2232
- sessionUpdate: "agent_message_chunk",
2233
- content: {
2234
- type: "text",
2235
- text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. ${outcome}${explanation}`,
2364
+ : local
2365
+ ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
2366
+ : `The session stays on ${message.original_model}.`;
2367
+ const fallbackSummary = `${message.original_model} declined this request${category}; ` +
2368
+ `retried with ${message.fallback_model}. ${outcome}`;
2369
+ const explanation = message.api_refusal_explanation || undefined;
2370
+ const fallbackNotice = explanation
2371
+ ? `${fallbackSummary}\n\n${explanation}`
2372
+ : fallbackSummary;
2373
+ // A silent model swap is a session-level advisory, not something the model said.
2374
+ // Clients that negotiated typed records get it as one; the rest keep the bold-label
2375
+ // transcript line, which was the only way to flag it before.
2376
+ if (supportsAirSessionFailures(this.clientCapabilities)) {
2377
+ const useDetails = explanation !== undefined &&
2378
+ fallbackSummary.length + 2 + explanation.length >
2379
+ MAX_INLINE_FAILURE_TITLE_LENGTH;
2380
+ await publishSessionFailure("advisory", {
2381
+ title: useDetails ? fallbackSummary : fallbackNotice,
2382
+ ...(useDetails ? { details: explanation } : {}),
2383
+ });
2384
+ }
2385
+ else {
2386
+ await sendUpdate({
2387
+ sessionId: message.session_id,
2388
+ update: {
2389
+ sessionUpdate: "agent_message_chunk",
2390
+ content: {
2391
+ type: "text",
2392
+ text: `**Model fallback:** ${fallbackNotice}`,
2393
+ },
2236
2394
  },
2237
- },
2238
- });
2395
+ });
2396
+ }
2239
2397
  if (persistent) {
2240
2398
  await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
2241
2399
  }
@@ -2473,6 +2631,7 @@ export class ClaudeAcpAgent {
2473
2631
  }
2474
2632
  if (session.cancelled) {
2475
2633
  if (!isAutonomousResult) {
2634
+ await clearFailuresFromEarlierTurns();
2476
2635
  stopReason = "cancelled";
2477
2636
  }
2478
2637
  break;
@@ -2508,6 +2667,7 @@ export class ClaudeAcpAgent {
2508
2667
  }
2509
2668
  break;
2510
2669
  }
2670
+ await clearFailuresFromEarlierTurns();
2511
2671
  // A refusal can arrive on any result subtype (and may even set
2512
2672
  // is_error), so handle it before the subtype switch — otherwise the
2513
2673
  // is_error throw below would surface it as an internal error. The
@@ -2544,10 +2704,15 @@ export class ClaudeAcpAgent {
2544
2704
  settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
2545
2705
  break;
2546
2706
  }
2707
+ if (!message.is_error && lastAssistantModel !== null) {
2708
+ const activeTurnId = session.activeTurn?.promptUuid;
2709
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
2710
+ (failure.severity === "warning" && failure.turnId === activeTurnId));
2711
+ }
2547
2712
  switch (message.subtype) {
2548
2713
  case "success": {
2549
2714
  if (message.result.includes("Please run /login")) {
2550
- failActive(RequestError.authRequired());
2715
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
2551
2716
  break;
2552
2717
  }
2553
2718
  if (message.stop_reason === "max_tokens") {
@@ -2555,7 +2720,7 @@ export class ClaudeAcpAgent {
2555
2720
  break;
2556
2721
  }
2557
2722
  if (message.is_error) {
2558
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2723
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
2559
2724
  break;
2560
2725
  }
2561
2726
  // The result text is forwarded in two cases. Local-only
@@ -2591,17 +2756,29 @@ export class ClaudeAcpAgent {
2591
2756
  break;
2592
2757
  }
2593
2758
  if (message.is_error) {
2594
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2759
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
2595
2760
  break;
2596
2761
  }
2597
2762
  stopReason = "end_turn";
2598
2763
  break;
2599
2764
  }
2600
2765
  case "error_max_budget_usd":
2766
+ if (message.is_error) {
2767
+ await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2768
+ break;
2769
+ }
2770
+ stopReason = "max_turn_requests";
2771
+ break;
2601
2772
  case "error_max_turns":
2773
+ if (message.is_error) {
2774
+ await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2775
+ break;
2776
+ }
2777
+ stopReason = "max_turn_requests";
2778
+ break;
2602
2779
  case "error_max_structured_output_retries":
2603
2780
  if (message.is_error) {
2604
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2781
+ await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2605
2782
  break;
2606
2783
  }
2607
2784
  stopReason = "max_turn_requests";
@@ -2875,6 +3052,10 @@ export class ClaudeAcpAgent {
2875
3052
  if (message.type === "assistant" && message.parent_tool_use_id === null) {
2876
3053
  lastAssistantUsage = snapshotFromUsage(message.message.usage);
2877
3054
  lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
3055
+ lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
3056
+ if (message.error || lastAssistantWasUsageLimit) {
3057
+ lastAssistantFailureTitle = assistantMessageText(message.message);
3058
+ }
2878
3059
  if (message.message.model && message.message.model !== "<synthetic>") {
2879
3060
  lastAssistantModel = message.message.model;
2880
3061
  }
@@ -2933,7 +3114,18 @@ export class ClaudeAcpAgent {
2933
3114
  break;
2934
3115
  }
2935
3116
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
2936
- failActive(RequestError.authRequired());
3117
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
3118
+ break;
3119
+ }
3120
+ // AIR receives this provider condition on the terminal prompt
3121
+ // response as a typed failure whose title is the exact assistant
3122
+ // error text captured above. Do not duplicate that text as an
3123
+ // ordinary assistant message; legacy clients retain the historical
3124
+ // transcript behavior.
3125
+ if (message.type === "assistant" &&
3126
+ message.parent_tool_use_id === null &&
3127
+ (message.error || isSyntheticUsageLimitMessage(message.message)) &&
3128
+ supportsAirSessionFailures(this.clientCapabilities)) {
2937
3129
  break;
2938
3130
  }
2939
3131
  let content;
@@ -3095,13 +3287,22 @@ export class ClaudeAcpAgent {
3095
3287
  }
3096
3288
  break;
3097
3289
  }
3098
- // `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
3099
- // flows) is safe to drop: turn lifecycle here is driven by
3100
- // results/idle, and the client owns its own transcript view.
3290
+ case "conversation_reset": {
3291
+ // The SDK has switched to a fresh conversation, whose Task* IDs
3292
+ // and task store are independent of the previous transcript.
3293
+ // Clear both the in-memory snapshot and the client's visible plan
3294
+ // before any follow-up prompt can republish stale tasks.
3295
+ session.taskState.clear();
3296
+ await this.publishTaskPlan(params.sessionId, session.taskState);
3297
+ break;
3298
+ }
3101
3299
  case "tool_use_summary":
3102
- case "auth_status":
3103
3300
  case "prompt_suggestion":
3104
- case "conversation_reset":
3301
+ break;
3302
+ case "auth_status":
3303
+ if (!message.isAuthenticating && message.error === undefined) {
3304
+ await sessionFailures.clear((failure) => failure.kind === "auth_required");
3305
+ }
3105
3306
  break;
3106
3307
  default:
3107
3308
  unreachable(message, this.logger);
@@ -3123,6 +3324,19 @@ export class ClaudeAcpAgent {
3123
3324
  message.includes("process exited with") ||
3124
3325
  message.includes("process terminated by signal") ||
3125
3326
  message.includes("Failed to write to process stdin"));
3327
+ if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
3328
+ if (!isHeldOpen(session.activeTurn)) {
3329
+ await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
3330
+ }
3331
+ else {
3332
+ // The held turn keeps its recorded PromptResponse outcome, while the
3333
+ // exhausted Query makes the session independently unrecoverable.
3334
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3335
+ }
3336
+ }
3337
+ else {
3338
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3339
+ }
3126
3340
  // Either way the query iterator is finished and the consumer is exiting,
3127
3341
  // so release its resources via closeQueryStream (idempotent). A process
3128
3342
  // death is unrecoverable, so additionally evict the session so the client
@@ -3136,7 +3350,9 @@ export class ClaudeAcpAgent {
3136
3350
  }
3137
3351
  else {
3138
3352
  this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
3139
- failAllTurns(error);
3353
+ failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
3354
+ ? internalErrorForClient({ errorKind: "transport_lost" })
3355
+ : error);
3140
3356
  this.closeQueryStream(session);
3141
3357
  }
3142
3358
  }
@@ -3584,9 +3800,28 @@ export class ClaudeAcpAgent {
3584
3800
  async replaySessionHistory(sessionId) {
3585
3801
  const toolUseCache = {};
3586
3802
  const messages = await getSessionMessages(sessionId);
3587
- const forwardSubagentText = this.sessions[sessionId]?.forwardSubagentText ??
3588
- supportsSubagentTranscript(this.clientCapabilities);
3803
+ const session = this.sessions[sessionId];
3804
+ const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
3805
+ const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
3806
+ const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
3807
+ const sessionFailures = session && supportsTypedFailures
3808
+ ? new SessionFailureController({
3809
+ sessionId,
3810
+ state: session.sessionFailureState,
3811
+ capabilities: this.clientCapabilities,
3812
+ isCurrent: () => this.sessions[sessionId] === session,
3813
+ sendUpdate: (notification) => this.client.sessionUpdate(notification),
3814
+ logger: this.logger,
3815
+ })
3816
+ : undefined;
3817
+ let replayTurnId;
3589
3818
  for (const message of messages) {
3819
+ if (message.type === "user" &&
3820
+ message.parent_tool_use_id === null &&
3821
+ typeof message.uuid === "string" &&
3822
+ message.uuid.length > 0) {
3823
+ replayTurnId = message.uuid;
3824
+ }
3590
3825
  // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
3591
3826
  // observe live (resumed/loaded sessions), so rewind/resume can translate
3592
3827
  // a client-supplied id without an extra getSessionMessages read. Not read
@@ -3602,6 +3837,21 @@ export class ClaudeAcpAgent {
3602
3837
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3603
3838
  continue;
3604
3839
  }
3840
+ // Capable clients saw every synthetic usage-limit message as a typed
3841
+ // failure live, so replay it at the same transcript position. The
3842
+ // preceding persisted user uuid is the live prompt uuid and therefore
3843
+ // recreates the same incident identity. Only the latest limit not
3844
+ // followed by a real model answer remains active internally.
3845
+ if (sessionFailures &&
3846
+ message.type === "assistant" &&
3847
+ message.parent_tool_use_id === null &&
3848
+ isSyntheticUsageLimitMessage(message.message)) {
3849
+ const title = assistantMessageText(message.message);
3850
+ if (title) {
3851
+ await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
3852
+ }
3853
+ continue;
3854
+ }
3605
3855
  // @ts-expect-error - untyped in SDK but we handle all of these
3606
3856
  let content = message.message.content;
3607
3857
  const parentToolUseId = parentToolUseIdOf(message);
@@ -3992,7 +4242,7 @@ export class ClaudeAcpAgent {
3992
4242
  sessionId,
3993
4243
  update: {
3994
4244
  sessionUpdate: "available_commands_update",
3995
- availableCommands: getAvailableSlashCommands(commands),
4245
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
3996
4246
  },
3997
4247
  });
3998
4248
  }
@@ -4506,15 +4756,7 @@ export class ClaudeAcpAgent {
4506
4756
  hooks: [
4507
4757
  createTaskHook({
4508
4758
  taskState,
4509
- onChange: async () => {
4510
- await this.client.sessionUpdate({
4511
- sessionId,
4512
- update: {
4513
- sessionUpdate: "plan",
4514
- entries: taskStateToPlanEntries(taskState),
4515
- },
4516
- });
4517
- },
4759
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
4518
4760
  }),
4519
4761
  ],
4520
4762
  },
@@ -4525,15 +4767,7 @@ export class ClaudeAcpAgent {
4525
4767
  hooks: [
4526
4768
  createTaskHook({
4527
4769
  taskState,
4528
- onChange: async () => {
4529
- await this.client.sessionUpdate({
4530
- sessionId,
4531
- update: {
4532
- sessionUpdate: "plan",
4533
- entries: taskStateToPlanEntries(taskState),
4534
- },
4535
- });
4536
- },
4770
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
4537
4771
  }),
4538
4772
  ],
4539
4773
  },
@@ -4762,6 +4996,7 @@ export class ClaudeAcpAgent {
4762
4996
  emittedAssistantText: false,
4763
4997
  owedTrailingIdles: 0,
4764
4998
  messageIdToUuid: new Map(),
4999
+ sessionFailureState: createSessionFailureState(),
4765
5000
  };
4766
5001
  return {
4767
5002
  sessionId,
@@ -5113,11 +5348,21 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
5113
5348
  category: "model",
5114
5349
  type: "select",
5115
5350
  currentValue: models.currentModelId,
5116
- options: models.availableModels.map((m) => ({
5117
- value: m.modelId,
5118
- name: m.name,
5119
- description: m.description ?? undefined,
5120
- })),
5351
+ options: models.availableModels.map((m) => {
5352
+ if (m.modelId === "default") {
5353
+ const defaultInfo = modelInfos.find((mi) => mi.value === "default");
5354
+ const resolvedModel = defaultInfo?.resolvedModel;
5355
+ if (resolvedModel) {
5356
+ const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
5357
+ return {
5358
+ value: m.modelId,
5359
+ name: m.name,
5360
+ description: namedMatch?.displayName ?? resolvedModel,
5361
+ };
5362
+ }
5363
+ }
5364
+ return { value: m.modelId, name: m.name, description: m.description ?? undefined };
5365
+ }),
5121
5366
  },
5122
5367
  ];
5123
5368
  // Add effort level option based on the currently selected model
@@ -5549,7 +5794,11 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
5549
5794
  resumedContextWindow,
5550
5795
  };
5551
5796
  }
5552
- function getAvailableSlashCommands(commands) {
5797
+ function getAvailableSlashCommands(commands,
5798
+ // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
5799
+ // the CLI's own terminal, which ACP clients aren't) — filtered alongside
5800
+ // the static list. Raw CLI names, matched before the MCP rename.
5801
+ terminalCommands) {
5553
5802
  const UNSUPPORTED_COMMANDS = [
5554
5803
  "clear",
5555
5804
  "cost",
@@ -5561,6 +5810,7 @@ function getAvailableSlashCommands(commands) {
5561
5810
  "todos",
5562
5811
  ];
5563
5812
  return commands
5813
+ .filter((command) => !terminalCommands?.includes(command.name))
5564
5814
  .map((command) => {
5565
5815
  const input = command.argumentHint
5566
5816
  ? {
@@ -5739,7 +5989,7 @@ function shouldEmitToolCall(toolName) {
5739
5989
  * are kept out of ACP's standard `title`, which clients may use as the shell
5740
5990
  * command preview, while still giving clients access to Claude's concise
5741
5991
  * human-readable title. */
5742
- function claudeCodeMetaFromToolUse(toolUse) {
5992
+ function claudeCodeMetaFromToolUse(toolUse, cwd) {
5743
5993
  const description = toolUse.name === "Bash" &&
5744
5994
  toolUse.input !== null &&
5745
5995
  typeof toolUse.input === "object" &&
@@ -5747,12 +5997,54 @@ function claudeCodeMetaFromToolUse(toolUse) {
5747
5997
  typeof toolUse.input.description === "string"
5748
5998
  ? toolUse.input.description
5749
5999
  : undefined;
6000
+ const skillName = toolUse.name === "Skill"
6001
+ ? toolUse.input?.skill
6002
+ : undefined;
6003
+ const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
5750
6004
  return {
5751
6005
  toolName: toolUse.name,
5752
6006
  ...(description ? { title: description } : {}),
5753
6007
  ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
6008
+ ...(skillName ? { skill: skillName } : {}),
6009
+ ...(skillPath ? { skillPath } : {}),
5754
6010
  };
5755
6011
  }
6012
+ /** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
6013
+ const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
6014
+ /**
6015
+ * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
6016
+ *
6017
+ * The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
6018
+ * actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
6019
+ * for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
6020
+ * directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
6021
+ * on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
6022
+ */
6023
+ function resolveSkillPath(skillName, cwd) {
6024
+ if (!cwd) {
6025
+ return undefined;
6026
+ }
6027
+ const colon = skillName.indexOf(":");
6028
+ const scope = colon < 0 ? undefined : skillName.slice(0, colon);
6029
+ const name = colon < 0 ? skillName : skillName.slice(colon + 1);
6030
+ if (!name) {
6031
+ return undefined;
6032
+ }
6033
+ const candidates = [];
6034
+ const addCandidates = (base) => {
6035
+ for (const container of SKILL_CONTAINER_DIRS) {
6036
+ candidates.push(path.join(base, container, name, "SKILL.md"));
6037
+ }
6038
+ };
6039
+ if (scope) {
6040
+ // A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
6041
+ addCandidates(path.join(cwd, scope));
6042
+ candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
6043
+ }
6044
+ addCandidates(cwd);
6045
+ addCandidates(os.homedir());
6046
+ return candidates.find((candidate) => existsSync(candidate));
6047
+ }
5756
6048
  /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
5757
6049
  * notification for a tool_use. Shared by every site that surfaces a tool call:
5758
6050
  * the streamed tool_use path (first encounter → tool_call, later encounter →
@@ -5763,7 +6055,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
5763
6055
  function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
5764
6056
  if (refine) {
5765
6057
  return {
5766
- _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
6058
+ _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
5767
6059
  toolCallId: toolUse.id,
5768
6060
  sessionUpdate: "tool_call_update",
5769
6061
  rawInput,
@@ -5772,7 +6064,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
5772
6064
  }
5773
6065
  return {
5774
6066
  _meta: {
5775
- claudeCode: claudeCodeMetaFromToolUse(toolUse),
6067
+ claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
5776
6068
  ...(toolUse.name === "Bash" && supportsTerminalOutput
5777
6069
  ? { terminal_info: { terminal_id: toolUse.id } }
5778
6070
  : {}),
@@ -5798,7 +6090,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
5798
6090
  const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
5799
6091
  return {
5800
6092
  _meta: {
5801
- claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
6093
+ claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
5802
6094
  },
5803
6095
  toolCallId: toolUse.id,
5804
6096
  sessionUpdate: "tool_call_update",
@@ -6081,20 +6373,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6081
6373
  }
6082
6374
  if (isTaskTool(toolUse.name)) {
6083
6375
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
6084
- // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
6085
- // and TaskGet are read-only so we just suppress their tool_call /
6086
- // tool_result events. The plan update is emitted as a snapshot of
6087
- // the accumulated state, mirroring the legacy TodoWrite behavior.
6376
+ // TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
6377
+ // reconciles it from the SDK's authoritative snapshot, which repairs
6378
+ // resumed or compacted sessions whose creating calls are no longer in
6379
+ // replay history. TaskGet is read-only and remains suppressed. Plan
6380
+ // updates always carry the full accumulated snapshot, mirroring the
6381
+ // legacy TodoWrite behavior.
6088
6382
  const isError = "is_error" in chunk && chunk.is_error;
6383
+ let shouldEmitTaskPlan = false;
6089
6384
  if (!isError) {
6090
6385
  if (toolUse.name === "TaskCreate") {
6091
- applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
6386
+ applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
6387
+ shouldEmitTaskPlan = true;
6092
6388
  }
6093
6389
  else if (toolUse.name === "TaskUpdate") {
6094
- applyTaskUpdate(taskState, toolUse.input);
6390
+ const input = toolUse.input;
6391
+ const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
6392
+ parseTaskUpdateOutput(chunk.content, input?.taskId);
6393
+ // Older CLI transcripts have no structured output, so retain the
6394
+ // input-based fallback. When an output is available, only apply a
6395
+ // confirmed update for the same task.
6396
+ if (!output || (output.success && output.taskId === input?.taskId)) {
6397
+ applyTaskUpdate(taskState, input);
6398
+ shouldEmitTaskPlan = true;
6399
+ }
6400
+ }
6401
+ else if (toolUse.name === "TaskList") {
6402
+ const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
6403
+ if (output) {
6404
+ applyTaskList(taskState, output);
6405
+ shouldEmitTaskPlan = true;
6406
+ }
6095
6407
  }
6096
6408
  }
6097
- if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
6409
+ if (shouldEmitTaskPlan) {
6098
6410
  update = {
6099
6411
  sessionUpdate: "plan",
6100
6412
  entries: taskStateToPlanEntries(taskState),