@agentclientprotocol/claude-agent-acp 0.65.0 → 0.67.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
@@ -1,7 +1,9 @@
1
1
  import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
2
  import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
3
+ import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
3
4
  import { execFile } from "node:child_process";
4
5
  import { randomUUID } from "node:crypto";
6
+ import { existsSync } from "node:fs";
5
7
  import * as fs from "node:fs/promises";
6
8
  import * as os from "node:os";
7
9
  import * as path from "node:path";
@@ -9,7 +11,7 @@ import { promisify } from "node:util";
9
11
  import packageJson from "../package.json" with { type: "json" };
10
12
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
11
13
  import { SettingsManager } from "./settings.js";
12
- import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
14
+ import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
13
15
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
14
16
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
15
17
  const execFileAsync = promisify(execFile);
@@ -99,11 +101,13 @@ function parseSteerRequest(params) {
99
101
  * result is the turn's real terminal.
100
102
  *
101
103
  * Deliberately fail-OPEN: an unknown future kind defaults to the user
102
- * lane. Misrouting a USER result into the autonomous lane hangs the
103
- * prompt un-detectably (the result is skipped, its trailing idle absorbed
104
- * as owed, so the #825 detector can't fire); misrouting an autonomous
105
- * result into the user lane is the bounded misattribution class this set
106
- * exists to reduce. */
104
+ * lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
105
+ * attribute this" marker, which gets the same safe default. Misrouting a
106
+ * USER result into the autonomous lane hangs the prompt un-detectably
107
+ * (the result is skipped, its trailing idle absorbed as owed, so the
108
+ * #825 detector can't fire); misrouting an autonomous result into the
109
+ * user lane is the bounded misattribution class this set exists to
110
+ * reduce. */
107
111
  const AUTONOMOUS_RESULT_ORIGINS = new Set([
108
112
  "task-notification",
109
113
  "peer",
@@ -154,9 +158,118 @@ const PROVIDER_ID = "main";
154
158
  */
155
159
  const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
156
160
  const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
161
+ // This is a protocol namespace, not user-facing branding: `jetbrains` owns the
162
+ // non-standard contract and `air` identifies the client defining its rendering
163
+ // semantics, without affecting unrelated JetBrains ACP clients.
164
+ const JETBRAINS_META_KEY = "jetbrains";
165
+ const AIR_META_KEY = "air";
166
+ const AIR_EXTENSION_VERSION_KEY = "version";
167
+ const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
168
+ const AIR_SESSION_FAILURE_KEY = "sessionFailure";
169
+ const AIR_EXTENSION_VERSION = 1;
157
170
  function supportsSubagentTranscript(capabilities) {
158
171
  return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
159
172
  }
173
+ const AIR_FAILURE_PRESENTATION = {
174
+ // A non-fatal notice whose wording comes from the adapter or the SDK, so this message is only the
175
+ // fallback for an advisory published without one.
176
+ advisory: {
177
+ message: "Claude reported a notice.",
178
+ retryable: false,
179
+ actions: [],
180
+ },
181
+ auth_required: {
182
+ message: "Sign in to continue using Claude.",
183
+ retryable: false,
184
+ actions: ["login"],
185
+ },
186
+ bad_request: {
187
+ message: "Claude could not process this request.",
188
+ retryable: false,
189
+ actions: ["new_turn"],
190
+ },
191
+ budget_exhausted: {
192
+ message: "This Claude session reached its configured budget.",
193
+ retryable: false,
194
+ actions: ["new_session"],
195
+ },
196
+ context_exhausted: {
197
+ message: "This Claude turn reached its configured limit.",
198
+ retryable: false,
199
+ actions: ["new_turn"],
200
+ },
201
+ internal_error: {
202
+ message: "Claude Agent encountered an internal error.",
203
+ retryable: true,
204
+ actions: ["retry"],
205
+ },
206
+ overloaded: {
207
+ message: "Claude is temporarily overloaded.",
208
+ retryable: true,
209
+ actions: ["retry"],
210
+ },
211
+ provider_error: {
212
+ message: "The model provider reported an error.",
213
+ retryable: true,
214
+ actions: ["retry"],
215
+ },
216
+ quota_exhausted: {
217
+ message: "The Claude account has no available quota.",
218
+ retryable: false,
219
+ actions: ["new_session"],
220
+ },
221
+ rate_limited: {
222
+ message: "Claude is temporarily rate limited.",
223
+ retryable: true,
224
+ actions: ["retry"],
225
+ },
226
+ transport_lost: {
227
+ message: "The connection to Claude was lost.",
228
+ retryable: false,
229
+ actions: ["new_session"],
230
+ },
231
+ worker_shutdown: {
232
+ message: "The Claude worker is shutting down.",
233
+ retryable: false,
234
+ actions: ["new_session"],
235
+ },
236
+ };
237
+ function supportsAirSessionFailures(capabilities) {
238
+ const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY];
239
+ const air = jetbrains?.[AIR_META_KEY];
240
+ const version = air?.[AIR_EXTENSION_VERSION_KEY];
241
+ const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
242
+ return (typeof version === "number" &&
243
+ Number.isFinite(version) &&
244
+ Number.isInteger(version) &&
245
+ version >= AIR_EXTENSION_VERSION &&
246
+ Array.isArray(advertised) &&
247
+ advertised.some((capability) => capability === AIR_SESSION_FAILURE_KEY));
248
+ }
249
+ function providerFailureCategory(errorKind) {
250
+ switch (errorKind) {
251
+ case "authentication_failed":
252
+ case "oauth_org_not_allowed":
253
+ return "auth_required";
254
+ case "billing_error":
255
+ return "quota_exhausted";
256
+ case "rate_limit":
257
+ return "rate_limited";
258
+ case "overloaded":
259
+ return "overloaded";
260
+ case "invalid_request":
261
+ case "model_not_found":
262
+ return "bad_request";
263
+ case "max_output_tokens":
264
+ return "context_exhausted";
265
+ case "server_error":
266
+ case "unknown":
267
+ case undefined:
268
+ return "provider_error";
269
+ default:
270
+ return "provider_error";
271
+ }
272
+ }
160
273
  function parentToolUseIdOf(message) {
161
274
  if (!("parent_tool_use_id" in message))
162
275
  return null;
@@ -733,9 +846,20 @@ export class ClaudeAcpAgent {
733
846
  // steering extension contract: advertises the `_session/steering` request
734
847
  // so clients know they may inject a follow-up into a running turn.
735
848
  _meta: {
849
+ [JETBRAINS_META_KEY]: {
850
+ [AIR_META_KEY]: {
851
+ [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
852
+ [AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY],
853
+ },
854
+ },
736
855
  steering: {
737
856
  supported: true,
738
857
  },
858
+ goal: {
859
+ version: GOAL_EXTENSION_VERSION,
860
+ controlMethod: GOAL_CONTROL_METHOD,
861
+ actions: [...GOAL_ACTIONS],
862
+ },
739
863
  },
740
864
  };
741
865
  }
@@ -960,6 +1084,9 @@ export class ClaudeAcpAgent {
960
1084
  if (session.queryClosed) {
961
1085
  throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
962
1086
  }
1087
+ if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
1088
+ await this.publishTaskPlan(params.sessionId, session.taskState);
1089
+ }
963
1090
  const userMessage = promptToClaude(params);
964
1091
  const promptUuid = randomUUID();
965
1092
  userMessage.uuid = promptUuid;
@@ -986,8 +1113,73 @@ export class ClaudeAcpAgent {
986
1113
  session.turnQueue.push(turn);
987
1114
  session.input.push(userMessage);
988
1115
  this.ensureConsumer(session, params.sessionId);
1116
+ await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid);
989
1117
  return response;
990
1118
  }
1119
+ async goal(params) {
1120
+ const command = params.action === "set" ? `/goal ${params.objective}` : "/goal clear";
1121
+ const prompt = [{ type: "text", text: command }];
1122
+ const steering = await this.steer({
1123
+ sessionId: params.sessionId,
1124
+ prompt,
1125
+ _meta: { steering: { idleBehavior: "promptRequired" } },
1126
+ });
1127
+ if (steering.outcome === "promptRequired") {
1128
+ await this.prompt({ sessionId: params.sessionId, prompt });
1129
+ }
1130
+ return {};
1131
+ }
1132
+ async publishGoal(sessionId, goal) {
1133
+ const session = this.sessions[sessionId];
1134
+ if (session) {
1135
+ session.lastPublishedGoal = goal;
1136
+ }
1137
+ await this.client.sessionUpdate({
1138
+ sessionId,
1139
+ update: {
1140
+ sessionUpdate: "session_info_update",
1141
+ _meta: { goal },
1142
+ },
1143
+ });
1144
+ }
1145
+ async publishTaskPlan(sessionId, taskState) {
1146
+ await this.client.sessionUpdate({
1147
+ sessionId,
1148
+ update: {
1149
+ sessionUpdate: "plan",
1150
+ entries: taskStateToPlanEntries(taskState),
1151
+ },
1152
+ });
1153
+ }
1154
+ async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
1155
+ const goalUpdate = goalUpdateFromPrompt(prompt);
1156
+ if (goalUpdate !== undefined) {
1157
+ const session = this.sessions[sessionId];
1158
+ if (session) {
1159
+ session.pendingGoalUpdate = {
1160
+ commandUuid,
1161
+ expected: goalUpdate,
1162
+ previous: session.lastPublishedGoal,
1163
+ started: false,
1164
+ };
1165
+ }
1166
+ await this.publishGoal(sessionId, goalUpdate);
1167
+ }
1168
+ }
1169
+ async publishRuntimeGoal(sessionId, goal) {
1170
+ const session = this.sessions[sessionId];
1171
+ const pending = session?.pendingGoalUpdate;
1172
+ if (pending) {
1173
+ const matchesPending = pending.expected === null
1174
+ ? goal === null
1175
+ : goal !== null && goal.objective === pending.expected.objective;
1176
+ if (!matchesPending) {
1177
+ return;
1178
+ }
1179
+ session.pendingGoalUpdate = undefined;
1180
+ }
1181
+ await this.publishGoal(sessionId, goal);
1182
+ }
991
1183
  /** Steer the session per the ACP steering wire protocol: inject a follow-up
992
1184
  * message into the turn that is currently running. If that turn already
993
1185
  * settled, the established default starts a new detached turn; Hosts may opt
@@ -1068,6 +1260,8 @@ export class ClaudeAcpAgent {
1068
1260
  turnInFlight.deferredSettle = undefined;
1069
1261
  }
1070
1262
  session.input.push(userMessage);
1263
+ const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1264
+ await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
1071
1265
  return { outcome: "injected" };
1072
1266
  }
1073
1267
  /** Lazily start the per-session consumer that drains the SDK query stream for
@@ -1157,6 +1351,115 @@ export class ClaudeAcpAgent {
1157
1351
  }
1158
1352
  await this.client.sessionUpdate(notification);
1159
1353
  };
1354
+ const isAdvisory = (failure) => failure.severity === "warning";
1355
+ const failureRevisions = new Map();
1356
+ const sessionFailureEpoch = randomUUID();
1357
+ const activeSessionFailures = new Map();
1358
+ let pendingWorkerShutdown = false;
1359
+ const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
1360
+ const sessionFailureMeta = (failure, phase) => {
1361
+ const presentation = AIR_FAILURE_PRESENTATION[failure.category];
1362
+ return {
1363
+ [JETBRAINS_META_KEY]: {
1364
+ [AIR_META_KEY]: {
1365
+ [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
1366
+ [AIR_SESSION_FAILURE_KEY]: {
1367
+ id: failure.id,
1368
+ revision: failure.revision,
1369
+ phase,
1370
+ category: failure.category,
1371
+ source: "claude",
1372
+ safeMessage: failure.safeMessage ?? presentation.message,
1373
+ retryable: presentation.retryable,
1374
+ actions: presentation.actions,
1375
+ ...(failure.turnId ? { turnId: failure.turnId } : {}),
1376
+ ...(failure.severity ? { severity: failure.severity } : {}),
1377
+ },
1378
+ },
1379
+ },
1380
+ };
1381
+ };
1382
+ const emitSessionFailure = async (failure, phase) => {
1383
+ if (!isCurrentConsumer()) {
1384
+ this.logger.error(`Session ${params.sessionId}: ignored AIR session failure from a stale query consumer`);
1385
+ return false;
1386
+ }
1387
+ try {
1388
+ await sendUpdate({
1389
+ sessionId: params.sessionId,
1390
+ update: {
1391
+ sessionUpdate: "session_info_update",
1392
+ _meta: sessionFailureMeta(failure, phase),
1393
+ },
1394
+ });
1395
+ return true;
1396
+ }
1397
+ catch (error) {
1398
+ this.logger.error(`Session ${params.sessionId}: failed to publish AIR session failure: ${error}`);
1399
+ return false;
1400
+ }
1401
+ };
1402
+ const clearSessionFailure = async (shouldClear = () => true) => {
1403
+ if (!supportsAirSessionFailures(this.clientCapabilities))
1404
+ return;
1405
+ for (const failure of [...activeSessionFailures.values()]) {
1406
+ if (!shouldClear(failure))
1407
+ continue;
1408
+ const cleared = {
1409
+ ...failure,
1410
+ revision: failure.revision + 1,
1411
+ };
1412
+ if (await emitSessionFailure(cleared, "cleared")) {
1413
+ failureRevisions.set(cleared.id, cleared.revision);
1414
+ activeSessionFailures.delete(cleared.id);
1415
+ }
1416
+ }
1417
+ };
1418
+ const createSessionFailure = async (category, options = {}) => {
1419
+ if (!supportsAirSessionFailures(this.clientCapabilities) || !isCurrentConsumer()) {
1420
+ return undefined;
1421
+ }
1422
+ // An advisory is not a failure of the turn: it keeps its own id namespace and its own slot, so
1423
+ // it neither clears an actionable error nor is cleared by one. Only the newest advisory
1424
+ // matters, so they all share one id and bump its revision.
1425
+ if (category === "advisory") {
1426
+ const id = `${params.sessionId}:notice:${sessionFailureEpoch}`;
1427
+ return {
1428
+ id,
1429
+ revision: (failureRevisions.get(id) ?? 0) + 1,
1430
+ category,
1431
+ severity: "warning",
1432
+ ...(options.safeMessage ? { safeMessage: options.safeMessage } : {}),
1433
+ };
1434
+ }
1435
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1436
+ const id = turnId
1437
+ ? `${turnId}:error`
1438
+ : `${params.sessionId}:session-error:${sessionFailureEpoch}`;
1439
+ await clearSessionFailure((failure) => failure.id !== id && !isAdvisory(failure));
1440
+ return {
1441
+ id,
1442
+ revision: (failureRevisions.get(id) ?? 0) + 1,
1443
+ category,
1444
+ ...(turnId ? { turnId } : {}),
1445
+ };
1446
+ };
1447
+ const publishSessionFailure = async (category, options = {}) => {
1448
+ const failure = await createSessionFailure(category, options);
1449
+ if (!failure)
1450
+ return;
1451
+ if (await emitSessionFailure(failure, "active")) {
1452
+ failureRevisions.set(failure.id, failure.revision);
1453
+ activeSessionFailures.set(failure.id, failure);
1454
+ }
1455
+ };
1456
+ const clearFailuresFromEarlierTurns = async () => {
1457
+ const activeTurnId = session.activeTurn?.promptUuid;
1458
+ // Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
1459
+ // They are session-scoped and stay until superseded or dismissed by the user.
1460
+ await clearSessionFailure((failure) => failure.turnId !== activeTurnId && !isAdvisory(failure));
1461
+ };
1462
+ const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
1160
1463
  const resetTurnScratch = () => {
1161
1464
  lastAssistantTotalUsage = null;
1162
1465
  lastAssistantUsage = null;
@@ -1440,6 +1743,7 @@ export class ClaudeAcpAgent {
1440
1743
  disarmForceCancel(session);
1441
1744
  const turn = session.activeTurn;
1442
1745
  if (!turn || turn.settled) {
1746
+ this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
1443
1747
  return;
1444
1748
  }
1445
1749
  turn.settled = true;
@@ -1453,6 +1757,32 @@ export class ClaudeAcpAgent {
1453
1757
  session.emittedAssistantText = false;
1454
1758
  turn.reject(error);
1455
1759
  };
1760
+ /** Complete a negotiated terminal failure on the prompt response itself,
1761
+ * which is the canonical AIR carrier. Legacy clients keep the historical
1762
+ * JSON-RPC rejection path. */
1763
+ const failActiveWithSessionFailure = async (category, error) => {
1764
+ if (!supportsAirSessionFailures(this.clientCapabilities)) {
1765
+ failActive(error);
1766
+ return;
1767
+ }
1768
+ if (!session.activeTurn || session.activeTurn.settled) {
1769
+ this.logger.error(`Session ${params.sessionId}: cannot attach ${category} to a prompt response because no active turn exists; publishing a session-scoped failure`);
1770
+ await publishSessionFailure(category, { turnScoped: false });
1771
+ return;
1772
+ }
1773
+ const failure = await createSessionFailure(category);
1774
+ if (!failure) {
1775
+ failActive(error);
1776
+ return;
1777
+ }
1778
+ failureRevisions.set(failure.id, failure.revision);
1779
+ activeSessionFailures.set(failure.id, failure);
1780
+ settleActive({
1781
+ stopReason: "end_turn",
1782
+ usage: sessionUsage(session),
1783
+ _meta: sessionFailureMeta(failure, "active"),
1784
+ });
1785
+ };
1456
1786
  /** Reject every in-flight turn — used when the stream dies. */
1457
1787
  const failAllTurns = (error) => {
1458
1788
  disarmForceCancel(session);
@@ -1570,6 +1900,24 @@ export class ClaudeAcpAgent {
1570
1900
  pendingNext = null;
1571
1901
  const { value: message, done } = raced.result;
1572
1902
  if (done || !message) {
1903
+ if (pendingWorkerShutdown) {
1904
+ pendingWorkerShutdown = false;
1905
+ if (session.activeTurn) {
1906
+ if (!isHeldOpen(session.activeTurn)) {
1907
+ await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
1908
+ }
1909
+ else {
1910
+ // The held turn already has its authoritative terminal outcome,
1911
+ // but EOF permanently closes the non-revivable Query. Preserve
1912
+ // the turn result below and report the independent session-health
1913
+ // failure without a turnId so AIR can offer a new session.
1914
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1915
+ }
1916
+ }
1917
+ else {
1918
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1919
+ }
1920
+ }
1573
1921
  // The stream ended. Settle the in-flight turns FIRST, then release the
1574
1922
  // stream resources — same order as the error paths (failAllTurns before
1575
1923
  // closeQueryStream). Settling is the user-facing contract; resource
@@ -1698,6 +2046,14 @@ export class ClaudeAcpAgent {
1698
2046
  }
1699
2047
  continue;
1700
2048
  }
2049
+ // `active_goal` is emitted by the Claude runtime but is not currently
2050
+ // included in the public SDKMessage union. Handle it before the
2051
+ // exhaustive switch and publish only the provider-neutral ACP shape.
2052
+ if (message.type === "active_goal") {
2053
+ const activeGoal = message;
2054
+ await this.publishRuntimeGoal(params.sessionId, toGoalSnapshot(activeGoal));
2055
+ continue;
2056
+ }
1701
2057
  switch (message.type) {
1702
2058
  case "system":
1703
2059
  switch (message.subtype) {
@@ -1713,6 +2069,23 @@ export class ClaudeAcpAgent {
1713
2069
  // updated Fast mode state; reconcile it with what we seeded at
1714
2070
  // session creation.
1715
2071
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
2072
+ // Terminal-bound slash commands (absent when none, and on
2073
+ // older CLIs). The session/new advertisement runs before any
2074
+ // init frame can be observed, so the first latch (or a
2075
+ // genuine change) re-publishes the now-filtered list.
2076
+ if (message.terminal_slash_commands &&
2077
+ JSON.stringify(message.terminal_slash_commands) !==
2078
+ JSON.stringify(session.terminalSlashCommands)) {
2079
+ session.terminalSlashCommands = message.terminal_slash_commands;
2080
+ try {
2081
+ await this.sendAvailableCommandsUpdate(message.session_id);
2082
+ }
2083
+ catch (error) {
2084
+ // Advisory reconcile only — the client keeps its current
2085
+ // (unfiltered) list; never fail the turn over it.
2086
+ this.logger.error(`Failed to re-advertise slash commands: ${error}`);
2087
+ }
2088
+ }
1716
2089
  break;
1717
2090
  case "status": {
1718
2091
  // These banners count as delivered text (via sendUpdate), so
@@ -1905,7 +2278,7 @@ export class ClaudeAcpAgent {
1905
2278
  // the next prompt; only a timer could tell those apart.
1906
2279
  this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
1907
2280
  `for the active turn; failing the in-flight prompt (issue #825)`);
1908
- failActive(RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
2281
+ await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
1909
2282
  }
1910
2283
  // The SDK generates the session title in a background task and
1911
2284
  // persists it to the session file; `idle` is the turn-over
@@ -1963,7 +2336,7 @@ export class ClaudeAcpAgent {
1963
2336
  sessionId: message.session_id,
1964
2337
  update: {
1965
2338
  sessionUpdate: "available_commands_update",
1966
- availableCommands: getAvailableSlashCommands(message.commands),
2339
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
1967
2340
  },
1968
2341
  });
1969
2342
  break;
@@ -2105,9 +2478,14 @@ export class ClaudeAcpAgent {
2105
2478
  }
2106
2479
  break;
2107
2480
  case "worker_shutting_down":
2108
- // A Remote Control worker announced a graceful teardown. This is a
2109
- // live-tail signal for remote clients to explain why a session went
2110
- // away; it's not meaningful for a local stdio ACP session.
2481
+ // Defer until stream end. The announcement is durable and may be
2482
+ // replayed before later frames, but those frames do not prove that
2483
+ // a new worker epoch began: they can be buffered output from the
2484
+ // shutting-down worker. Keep the signal armed until the transport
2485
+ // actually ends. Deliberately do not add a quiet-period timer: the
2486
+ // iterator has no replay/live boundary, so a timeout could publish
2487
+ // while a slow replay is still in flight.
2488
+ pendingWorkerShutdown = true;
2111
2489
  break;
2112
2490
  case "elicitation_complete": {
2113
2491
  // A url-mode MCP elicitation finished server-side. Let the client
@@ -2144,7 +2522,15 @@ export class ClaudeAcpAgent {
2144
2522
  // CLIs, where "revert" marked a turn-only fallback — for that
2145
2523
  // direction the session stays on the original model, so skip
2146
2524
  // the persistent-swap claim and the state sync.
2147
- const persistent = message.direction !== "revert";
2525
+ //
2526
+ // `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
2527
+ // "local" means a subagent / side-question / background fork
2528
+ // response fell back and the session model is unchanged, so
2529
+ // syncing the picker would advertise a model the session
2530
+ // isn't running. Absent scope means an older CLI, where every
2531
+ // retry was a session-level swap — treat as "session".
2532
+ const local = message.scope === "local";
2533
+ const persistent = message.direction !== "revert" && !local;
2148
2534
  const category = message.api_refusal_category
2149
2535
  ? ` (${message.api_refusal_category})`
2150
2536
  : "";
@@ -2153,17 +2539,29 @@ export class ClaudeAcpAgent {
2153
2539
  : "";
2154
2540
  const outcome = persistent
2155
2541
  ? `The session will continue on ${message.fallback_model}.`
2156
- : `The session stays on ${message.original_model}.`;
2157
- await sendUpdate({
2158
- sessionId: message.session_id,
2159
- update: {
2160
- sessionUpdate: "agent_message_chunk",
2161
- content: {
2162
- type: "text",
2163
- text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. ${outcome}${explanation}`,
2542
+ : local
2543
+ ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
2544
+ : `The session stays on ${message.original_model}.`;
2545
+ const fallbackNotice = `${message.original_model} declined this request${category}; ` +
2546
+ `retried with ${message.fallback_model}. ${outcome}${explanation}`;
2547
+ // A silent model swap is a session-level advisory, not something the model said.
2548
+ // Clients that negotiated typed records get it as one; the rest keep the bold-label
2549
+ // transcript line, which was the only way to flag it before.
2550
+ if (supportsAirSessionFailures(this.clientCapabilities)) {
2551
+ await publishSessionFailure("advisory", { safeMessage: fallbackNotice });
2552
+ }
2553
+ else {
2554
+ await sendUpdate({
2555
+ sessionId: message.session_id,
2556
+ update: {
2557
+ sessionUpdate: "agent_message_chunk",
2558
+ content: {
2559
+ type: "text",
2560
+ text: `**Model fallback:** ${fallbackNotice}`,
2561
+ },
2164
2562
  },
2165
- },
2166
- });
2563
+ });
2564
+ }
2167
2565
  if (persistent) {
2168
2566
  await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
2169
2567
  }
@@ -2268,6 +2666,20 @@ export class ClaudeAcpAgent {
2268
2666
  if (!isAutonomousResult) {
2269
2667
  recordResultForOrphanCommands();
2270
2668
  ensureActiveTurn();
2669
+ // Once the submitted goal command has produced its own result,
2670
+ // no older runtime update can still precede it in the ordered
2671
+ // SDK stream. Stop suppressing updates even when this runtime
2672
+ // omitted the matching active_goal notification entirely.
2673
+ if (session.pendingGoalUpdate?.started) {
2674
+ const pendingGoalUpdate = session.pendingGoalUpdate;
2675
+ session.pendingGoalUpdate = undefined;
2676
+ const goalCommandFailed = message.is_error ||
2677
+ message.stop_reason === "refusal" ||
2678
+ ("result" in message && message.result.includes("Please run /login"));
2679
+ if (goalCommandFailed) {
2680
+ await this.publishGoal(params.sessionId, pendingGoalUpdate.previous ?? null);
2681
+ }
2682
+ }
2271
2683
  }
2272
2684
  // A result closes the stretch of output it terminates: snapshot
2273
2685
  // the delivery record — AFTER ensureActiveTurn, whose held-turn
@@ -2387,6 +2799,7 @@ export class ClaudeAcpAgent {
2387
2799
  }
2388
2800
  if (session.cancelled) {
2389
2801
  if (!isAutonomousResult) {
2802
+ await clearFailuresFromEarlierTurns();
2390
2803
  stopReason = "cancelled";
2391
2804
  }
2392
2805
  break;
@@ -2422,6 +2835,7 @@ export class ClaudeAcpAgent {
2422
2835
  }
2423
2836
  break;
2424
2837
  }
2838
+ await clearFailuresFromEarlierTurns();
2425
2839
  // A refusal can arrive on any result subtype (and may even set
2426
2840
  // is_error), so handle it before the subtype switch — otherwise the
2427
2841
  // is_error throw below would surface it as an internal error. The
@@ -2447,10 +2861,24 @@ export class ClaudeAcpAgent {
2447
2861
  settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
2448
2862
  break;
2449
2863
  }
2864
+ // A priority:'now' steer can make the SDK terminate the
2865
+ // interrupted cycle with an error-shaped diagnostic result
2866
+ // before it replays the injected message's echo. That result is
2867
+ // not the steered command's outcome: keep it on the steer lane
2868
+ // and let the cycle after the pending echo decide the turn.
2869
+ if (message.is_error &&
2870
+ isSteering(session.activeTurn) &&
2871
+ session.activeTurn.steeredEchoes.size > 0) {
2872
+ settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
2873
+ break;
2874
+ }
2875
+ if (!message.is_error) {
2876
+ await clearSessionFailure();
2877
+ }
2450
2878
  switch (message.subtype) {
2451
2879
  case "success": {
2452
2880
  if (message.result.includes("Please run /login")) {
2453
- failActive(RequestError.authRequired());
2881
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
2454
2882
  break;
2455
2883
  }
2456
2884
  if (message.stop_reason === "max_tokens") {
@@ -2458,7 +2886,7 @@ export class ClaudeAcpAgent {
2458
2886
  break;
2459
2887
  }
2460
2888
  if (message.is_error) {
2461
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2889
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.result));
2462
2890
  break;
2463
2891
  }
2464
2892
  // The result text is forwarded in two cases. Local-only
@@ -2494,17 +2922,29 @@ export class ClaudeAcpAgent {
2494
2922
  break;
2495
2923
  }
2496
2924
  if (message.is_error) {
2497
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2925
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2498
2926
  break;
2499
2927
  }
2500
2928
  stopReason = "end_turn";
2501
2929
  break;
2502
2930
  }
2503
2931
  case "error_max_budget_usd":
2932
+ if (message.is_error) {
2933
+ await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2934
+ break;
2935
+ }
2936
+ stopReason = "max_turn_requests";
2937
+ break;
2504
2938
  case "error_max_turns":
2939
+ if (message.is_error) {
2940
+ await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2941
+ break;
2942
+ }
2943
+ stopReason = "max_turn_requests";
2944
+ break;
2505
2945
  case "error_max_structured_output_retries":
2506
2946
  if (message.is_error) {
2507
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2947
+ await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2508
2948
  break;
2509
2949
  }
2510
2950
  stopReason = "max_turn_requests";
@@ -2683,6 +3123,9 @@ export class ClaudeAcpAgent {
2683
3123
  // is still promoted — activateTurn() clears the flag. The turn's own
2684
3124
  // echo is then dropped from the feed (the client already shows it).
2685
3125
  if (message.type === "user" && "uuid" in message && message.uuid) {
3126
+ if (session.pendingGoalUpdate?.commandUuid === message.uuid) {
3127
+ session.pendingGoalUpdate.started = true;
3128
+ }
2686
3129
  const queued = findUnsettledTurn(message.uuid);
2687
3130
  if (queued) {
2688
3131
  // Only (re)activate if this isn't already the active turn — a
@@ -2833,7 +3276,7 @@ export class ClaudeAcpAgent {
2833
3276
  break;
2834
3277
  }
2835
3278
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
2836
- failActive(RequestError.authRequired());
3279
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
2837
3280
  break;
2838
3281
  }
2839
3282
  let content;
@@ -2995,13 +3438,22 @@ export class ClaudeAcpAgent {
2995
3438
  }
2996
3439
  break;
2997
3440
  }
2998
- // `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
2999
- // flows) is safe to drop: turn lifecycle here is driven by
3000
- // results/idle, and the client owns its own transcript view.
3441
+ case "conversation_reset": {
3442
+ // The SDK has switched to a fresh conversation, whose Task* IDs
3443
+ // and task store are independent of the previous transcript.
3444
+ // Clear both the in-memory snapshot and the client's visible plan
3445
+ // before any follow-up prompt can republish stale tasks.
3446
+ session.taskState.clear();
3447
+ await this.publishTaskPlan(params.sessionId, session.taskState);
3448
+ break;
3449
+ }
3001
3450
  case "tool_use_summary":
3002
- case "auth_status":
3003
3451
  case "prompt_suggestion":
3004
- case "conversation_reset":
3452
+ break;
3453
+ case "auth_status":
3454
+ if (!message.isAuthenticating && message.error === undefined) {
3455
+ await clearSessionFailure((failure) => failure.category === "auth_required");
3456
+ }
3005
3457
  break;
3006
3458
  default:
3007
3459
  unreachable(message, this.logger);
@@ -3023,6 +3475,19 @@ export class ClaudeAcpAgent {
3023
3475
  message.includes("process exited with") ||
3024
3476
  message.includes("process terminated by signal") ||
3025
3477
  message.includes("Failed to write to process stdin"));
3478
+ if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
3479
+ if (!isHeldOpen(session.activeTurn)) {
3480
+ await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
3481
+ }
3482
+ else {
3483
+ // The held turn keeps its recorded PromptResponse outcome, while the
3484
+ // exhausted Query makes the session independently unrecoverable.
3485
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3486
+ }
3487
+ }
3488
+ else {
3489
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3490
+ }
3026
3491
  // Either way the query iterator is finished and the consumer is exiting,
3027
3492
  // so release its resources via closeQueryStream (idempotent). A process
3028
3493
  // death is unrecoverable, so additionally evict the session so the client
@@ -3036,7 +3501,9 @@ export class ClaudeAcpAgent {
3036
3501
  }
3037
3502
  else {
3038
3503
  this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
3039
- failAllTurns(error);
3504
+ failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
3505
+ ? internalErrorForClient({ errorKind: "transport_lost" })
3506
+ : error);
3040
3507
  this.closeQueryStream(session);
3041
3508
  }
3042
3509
  }
@@ -3078,6 +3545,14 @@ export class ClaudeAcpAgent {
3078
3545
  return;
3079
3546
  }
3080
3547
  session.cancelled = true;
3548
+ // A priority steer may still be queued in the SDK when cancellation
3549
+ // settles its owning turn. Its later echo matches no live turn, and its
3550
+ // result must be skipped rather than promoted onto the next prompt.
3551
+ if (isSteering(session.activeTurn)) {
3552
+ for (const uuid of session.activeTurn.steeredEchoes) {
3553
+ this.trackOrphanCommand(session, uuid, "pending");
3554
+ }
3555
+ }
3081
3556
  // Capture the orphan-accounting lane before anything can await: the
3082
3557
  // consumer latches msgLifecycleV1 when it drains the first system/init,
3083
3558
  // which can happen DURING the awaited interrupt() below — the receipt
@@ -3884,7 +4359,7 @@ export class ClaudeAcpAgent {
3884
4359
  sessionId,
3885
4360
  update: {
3886
4361
  sessionUpdate: "available_commands_update",
3887
- availableCommands: getAvailableSlashCommands(commands),
4362
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
3888
4363
  },
3889
4364
  });
3890
4365
  }
@@ -4398,15 +4873,7 @@ export class ClaudeAcpAgent {
4398
4873
  hooks: [
4399
4874
  createTaskHook({
4400
4875
  taskState,
4401
- onChange: async () => {
4402
- await this.client.sessionUpdate({
4403
- sessionId,
4404
- update: {
4405
- sessionUpdate: "plan",
4406
- entries: taskStateToPlanEntries(taskState),
4407
- },
4408
- });
4409
- },
4876
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
4410
4877
  }),
4411
4878
  ],
4412
4879
  },
@@ -4417,15 +4884,7 @@ export class ClaudeAcpAgent {
4417
4884
  hooks: [
4418
4885
  createTaskHook({
4419
4886
  taskState,
4420
- onChange: async () => {
4421
- await this.client.sessionUpdate({
4422
- sessionId,
4423
- update: {
4424
- sessionUpdate: "plan",
4425
- entries: taskStateToPlanEntries(taskState),
4426
- },
4427
- });
4428
- },
4887
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
4429
4888
  }),
4430
4889
  ],
4431
4890
  },
@@ -5005,11 +5464,21 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
5005
5464
  category: "model",
5006
5465
  type: "select",
5007
5466
  currentValue: models.currentModelId,
5008
- options: models.availableModels.map((m) => ({
5009
- value: m.modelId,
5010
- name: m.name,
5011
- description: m.description ?? undefined,
5012
- })),
5467
+ options: models.availableModels.map((m) => {
5468
+ if (m.modelId === "default") {
5469
+ const defaultInfo = modelInfos.find((mi) => mi.value === "default");
5470
+ const resolvedModel = defaultInfo?.resolvedModel;
5471
+ if (resolvedModel) {
5472
+ const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
5473
+ return {
5474
+ value: m.modelId,
5475
+ name: m.name,
5476
+ description: namedMatch?.displayName ?? resolvedModel,
5477
+ };
5478
+ }
5479
+ }
5480
+ return { value: m.modelId, name: m.name, description: m.description ?? undefined };
5481
+ }),
5013
5482
  },
5014
5483
  ];
5015
5484
  // Add effort level option based on the currently selected model
@@ -5441,7 +5910,11 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
5441
5910
  resumedContextWindow,
5442
5911
  };
5443
5912
  }
5444
- function getAvailableSlashCommands(commands) {
5913
+ function getAvailableSlashCommands(commands,
5914
+ // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
5915
+ // the CLI's own terminal, which ACP clients aren't) — filtered alongside
5916
+ // the static list. Raw CLI names, matched before the MCP rename.
5917
+ terminalCommands) {
5445
5918
  const UNSUPPORTED_COMMANDS = [
5446
5919
  "clear",
5447
5920
  "cost",
@@ -5453,6 +5926,7 @@ function getAvailableSlashCommands(commands) {
5453
5926
  "todos",
5454
5927
  ];
5455
5928
  return commands
5929
+ .filter((command) => !terminalCommands?.includes(command.name))
5456
5930
  .map((command) => {
5457
5931
  const input = command.argumentHint
5458
5932
  ? {
@@ -5631,7 +6105,7 @@ function shouldEmitToolCall(toolName) {
5631
6105
  * are kept out of ACP's standard `title`, which clients may use as the shell
5632
6106
  * command preview, while still giving clients access to Claude's concise
5633
6107
  * human-readable title. */
5634
- function claudeCodeMetaFromToolUse(toolUse) {
6108
+ function claudeCodeMetaFromToolUse(toolUse, cwd) {
5635
6109
  const description = toolUse.name === "Bash" &&
5636
6110
  toolUse.input !== null &&
5637
6111
  typeof toolUse.input === "object" &&
@@ -5639,12 +6113,54 @@ function claudeCodeMetaFromToolUse(toolUse) {
5639
6113
  typeof toolUse.input.description === "string"
5640
6114
  ? toolUse.input.description
5641
6115
  : undefined;
6116
+ const skillName = toolUse.name === "Skill"
6117
+ ? toolUse.input?.skill
6118
+ : undefined;
6119
+ const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
5642
6120
  return {
5643
6121
  toolName: toolUse.name,
5644
6122
  ...(description ? { title: description } : {}),
5645
6123
  ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
6124
+ ...(skillName ? { skill: skillName } : {}),
6125
+ ...(skillPath ? { skillPath } : {}),
5646
6126
  };
5647
6127
  }
6128
+ /** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
6129
+ const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
6130
+ /**
6131
+ * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
6132
+ *
6133
+ * The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
6134
+ * actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
6135
+ * for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
6136
+ * directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
6137
+ * on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
6138
+ */
6139
+ function resolveSkillPath(skillName, cwd) {
6140
+ if (!cwd) {
6141
+ return undefined;
6142
+ }
6143
+ const colon = skillName.indexOf(":");
6144
+ const scope = colon < 0 ? undefined : skillName.slice(0, colon);
6145
+ const name = colon < 0 ? skillName : skillName.slice(colon + 1);
6146
+ if (!name) {
6147
+ return undefined;
6148
+ }
6149
+ const candidates = [];
6150
+ const addCandidates = (base) => {
6151
+ for (const container of SKILL_CONTAINER_DIRS) {
6152
+ candidates.push(path.join(base, container, name, "SKILL.md"));
6153
+ }
6154
+ };
6155
+ if (scope) {
6156
+ // A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
6157
+ addCandidates(path.join(cwd, scope));
6158
+ candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
6159
+ }
6160
+ addCandidates(cwd);
6161
+ addCandidates(os.homedir());
6162
+ return candidates.find((candidate) => existsSync(candidate));
6163
+ }
5648
6164
  /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
5649
6165
  * notification for a tool_use. Shared by every site that surfaces a tool call:
5650
6166
  * the streamed tool_use path (first encounter → tool_call, later encounter →
@@ -5655,7 +6171,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
5655
6171
  function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
5656
6172
  if (refine) {
5657
6173
  return {
5658
- _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
6174
+ _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
5659
6175
  toolCallId: toolUse.id,
5660
6176
  sessionUpdate: "tool_call_update",
5661
6177
  rawInput,
@@ -5664,7 +6180,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
5664
6180
  }
5665
6181
  return {
5666
6182
  _meta: {
5667
- claudeCode: claudeCodeMetaFromToolUse(toolUse),
6183
+ claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
5668
6184
  ...(toolUse.name === "Bash" && supportsTerminalOutput
5669
6185
  ? { terminal_info: { terminal_id: toolUse.id } }
5670
6186
  : {}),
@@ -5690,7 +6206,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
5690
6206
  const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
5691
6207
  return {
5692
6208
  _meta: {
5693
- claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
6209
+ claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
5694
6210
  },
5695
6211
  toolCallId: toolUse.id,
5696
6212
  sessionUpdate: "tool_call_update",
@@ -5973,20 +6489,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5973
6489
  }
5974
6490
  if (isTaskTool(toolUse.name)) {
5975
6491
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
5976
- // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
5977
- // and TaskGet are read-only so we just suppress their tool_call /
5978
- // tool_result events. The plan update is emitted as a snapshot of
5979
- // the accumulated state, mirroring the legacy TodoWrite behavior.
6492
+ // TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
6493
+ // reconciles it from the SDK's authoritative snapshot, which repairs
6494
+ // resumed or compacted sessions whose creating calls are no longer in
6495
+ // replay history. TaskGet is read-only and remains suppressed. Plan
6496
+ // updates always carry the full accumulated snapshot, mirroring the
6497
+ // legacy TodoWrite behavior.
5980
6498
  const isError = "is_error" in chunk && chunk.is_error;
6499
+ let shouldEmitTaskPlan = false;
5981
6500
  if (!isError) {
5982
6501
  if (toolUse.name === "TaskCreate") {
5983
- applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
6502
+ applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
6503
+ shouldEmitTaskPlan = true;
5984
6504
  }
5985
6505
  else if (toolUse.name === "TaskUpdate") {
5986
- applyTaskUpdate(taskState, toolUse.input);
6506
+ const input = toolUse.input;
6507
+ const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
6508
+ parseTaskUpdateOutput(chunk.content, input?.taskId);
6509
+ // Older CLI transcripts have no structured output, so retain the
6510
+ // input-based fallback. When an output is available, only apply a
6511
+ // confirmed update for the same task.
6512
+ if (!output || (output.success && output.taskId === input?.taskId)) {
6513
+ applyTaskUpdate(taskState, input);
6514
+ shouldEmitTaskPlan = true;
6515
+ }
6516
+ }
6517
+ else if (toolUse.name === "TaskList") {
6518
+ const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
6519
+ if (output) {
6520
+ applyTaskList(taskState, output);
6521
+ shouldEmitTaskPlan = true;
6522
+ }
5987
6523
  }
5988
6524
  }
5989
- if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
6525
+ if (shouldEmitTaskPlan) {
5990
6526
  update = {
5991
6527
  sessionUpdate: "plan",
5992
6528
  entries: taskStateToPlanEntries(taskState),
@@ -6235,6 +6771,7 @@ export function runAcp() {
6235
6771
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
6236
6772
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
6237
6773
  .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
6774
+ .onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
6238
6775
  .connect(stream);
6239
6776
  agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
6240
6777
  return { connection, agent };