@lucascouts/claude-agent-acp-plus 0.34.1 → 0.36.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
@@ -22,6 +22,7 @@ import { createThinkingConfigOption, effectiveThinkingConfig, modelCanDisableThi
22
22
  import { applyLegacyUltracodeEffort, applyUltracodeReadback, applyUltracodeToggle, effortFlagSettings, isLegacyUltracodeEffort, isUltracodeAvailable, readUltracodeState, resolveUltracodeRequested, ULTRACODE_CONFIG_ID, ultracodeConfigOption, } from "./ultracode.js";
23
23
  import { deleteSessionIfStored } from "./session-delete.js";
24
24
  import { applyPlanUpdatesToOptions, createPlanUpdatesConfigOption, resolvePlanUpdatesAtSpawn, resolvePlanUpdatesRequested, TODO_TOOLS_ENV, } from "./plan-updates.js";
25
+ import { planRemovedUpdate, planSessionUpdate, supportsPlanOperations } from "./keyed-plan.js";
25
26
  import { handleRewindCommand, parseRewindInvocation } from "./rewind-command.js";
26
27
  import { resolvePermissionMode, sessionAllowsBypass } from "./permissions/modes.js";
27
28
  import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
@@ -30,8 +31,8 @@ import { buildClaudePermissionPresentation } from "./permissions/presentation.js
30
31
  import { decodeClaudePermissionResponse } from "./permissions/response.js";
31
32
  import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
32
33
  import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
33
- import { clientSupportsNotices, MAX_NOTICE_TITLE_LENGTH, normalizeNoticeText, noticeOrTranscriptUpdate, sentenceCase, splitNoticeText, } from "./session-notices.js";
34
- import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, shellDescription, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
34
+ import { clientSupportsNotices, collapseHookSpeaker, MAX_NOTICE_TITLE_LENGTH, normalizeNoticeText, noticeOrTranscriptUpdate, sentenceCase, splitNoticeText, } from "./session-notices.js";
35
+ import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, shellDescription, unregisterHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
35
36
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
36
37
  import { acceptedPlanToolResult, ExitPlanCoordinator, exitPlanModeRawOutput, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
37
38
  import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID, PLAN_UPDATES_CONFIG_ID, } from "./session-config-ids.js";
@@ -45,6 +46,7 @@ import { AccountUsageTracker } from "./account-usage.js";
45
46
  import { readFreshLimits, sharedSampleMaxAgeMs, writeLimits } from "./quota-cache.js";
46
47
  import { ACCOUNT_CONFIG_ID, accountForValue, accountInForce, createAccountConfigOption, envForAccount, readDeclaredAccounts, } from "./accounts.js";
47
48
  import { isUsageCommandText, structuredUsageMarkdown, syntheticLocalCommand, } from "./usage-markdown.js";
49
+ import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "./startup-failure.js";
48
50
  export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
49
51
  import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
50
52
  import { TASKS_CONTRACT_VERSION, TASKS_META_KEY, normaliseStatus, TailPoller, TaskFeed, TaskFeedPublisher, } from "./task-feed.js";
@@ -418,30 +420,55 @@ function subagentLaunchOf(parentSessionId, session, message) {
418
420
  launcherTaskId: message.parent_task_id,
419
421
  toolUseId: message.tool_use_id,
420
422
  agentId: message.task_id,
421
- title: name ?? message.subagent_type ?? message.description,
422
- description: message.description,
423
+ ...subagentLabels(name, message.description, message.subagent_type),
423
424
  prompt,
424
425
  cancellable: typeof session.query.stopTask === "function",
425
426
  };
426
427
  }
428
+ /**
429
+ * The RFD labels for a child (upstream #1479). The title is what tells siblings apart: the
430
+ * agent's name when it has one, else the task's short description (never the prompt), else its
431
+ * role. The description is the role, its `subagent_type`, unless the role is already the title.
432
+ * Before this, siblings of one type all read "Explore".
433
+ */
434
+ export function subagentLabels(name, task, role) {
435
+ const text = (value) => (value && value.trim() ? value : undefined);
436
+ const agentRole = text(role);
437
+ const title = text(name) ?? text(task) ?? agentRole ?? "Subagent";
438
+ return { title, ...(agentRole && agentRole !== title ? { description: agentRole } : {}) };
439
+ }
427
440
  /** A child read back from a transcript, in the shape `ChildSessionRouter`
428
441
  * announces: the same title rule as a live launch (`subagentLaunchOf`). */
429
442
  function childLaunchOf(parentSessionId, session, toolUseId, record, input) {
430
443
  const text = (value) => (typeof value === "string" && value.trim() ? value : undefined);
431
- const description = record.description ?? text(input.description) ?? "";
432
444
  return {
433
445
  parentSessionId,
434
446
  launcherTaskId: record.launcherAgentId,
435
447
  toolUseId,
436
448
  agentId: record.agentId,
437
- title: record.name ?? text(input.name) ?? record.agentType ?? (description || "Subagent"),
438
- description,
449
+ ...subagentLabels(record.name ?? text(input.name), record.description ?? text(input.description), record.agentType ?? text(input.subagent_type)),
439
450
  prompt: text(input.prompt),
440
451
  // History, not a task the CLI holds: no Stop until it runs again, when its
441
452
  // live launch advertises what the SDK can stop (design.md, Security).
442
453
  cancellable: false,
443
454
  };
444
455
  }
456
+ /** Whether a permission request from `agentID` belongs to work that blocks the turn: the main
457
+ * thread (no agent id), a foreground subagent, or a subagent launched by one. A task this
458
+ * session never registered is not foreground work (upstream #1466, `blocksForeground`). */
459
+ function blocksForeground(session, agentID) {
460
+ const seen = new Set();
461
+ for (let id = agentID; id !== undefined && !seen.has(id);) {
462
+ seen.add(id);
463
+ const task = session.liveBackgroundTasks.get(id);
464
+ if (!task)
465
+ return false;
466
+ if (task.backgrounded)
467
+ return false;
468
+ id = task.parentTaskId;
469
+ }
470
+ return true;
471
+ }
445
472
  /** The text of a user message whose content is a string or one text block. */
446
473
  function userText(content) {
447
474
  if (typeof content === "string")
@@ -750,6 +777,16 @@ export function isReplayHiddenMetaMessage(message) {
750
777
  export function isLocalCommandMetadata(content) {
751
778
  return stripLocalCommandMetadata(content) === null;
752
779
  }
780
+ /**
781
+ * True when CLI error text tells the user to sign in with `/login`. The CLI
782
+ * words this several ways — "Not logged in · Please run /login", "Not logged
783
+ * in · Run /login", "Login expired · Run /login to sign in again, or
784
+ * re-authenticate your Anthropic profile" — so match the "run /login"
785
+ * instruction rather than one sentence.
786
+ */
787
+ export function isLoginRequiredText(text) {
788
+ return /\brun \/login\b/i.test(text);
789
+ }
753
790
  /**
754
791
  * True for the synthetic assistant message the CLI injects into the transcript
755
792
  * when a turn fails authentication (e.g. "Not logged in · Please run /login",
@@ -762,9 +799,13 @@ export function isLocalCommandMetadata(content) {
762
799
  * session/load even after the user has logged back in.
763
800
  *
764
801
  * Takes the API message (`message.message`), which replay only knows as
765
- * `unknown`. The persisted record's structured `error: "authentication_failed"`
766
- * marker is stripped by `getSessionMessages`, so the synthetic model + text is
767
- * all both paths have to match on.
802
+ * `unknown`. The persisted record's structured `error` marker is stripped by
803
+ * `getSessionMessages`, and the CLI does not always set it to
804
+ * `authentication_failed` anyway: an expired login with no refresh token
805
+ * ("Login expired · Run /login to sign in again, …") arrives as
806
+ * `invalid_request`. So the synthetic model + text is what both paths match on.
807
+ * Usage-limit banners are excluded because some of them also mention `/login`
808
+ * (to switch accounts) and have their own handling.
768
809
  */
769
810
  export function isSyntheticLoginMessage(apiMessage) {
770
811
  if (!apiMessage || typeof apiMessage !== "object") {
@@ -778,7 +819,8 @@ export function isSyntheticLoginMessage(apiMessage) {
778
819
  return (!!block &&
779
820
  block.type === "text" &&
780
821
  typeof block.text === "string" &&
781
- block.text.includes("Please run /login"));
822
+ isLoginRequiredText(block.text) &&
823
+ !isSyntheticUsageLimitMessage(apiMessage));
782
824
  }
783
825
  /**
784
826
  * Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
@@ -1483,13 +1525,14 @@ export class ClaudeAcpAgent {
1483
1525
  });
1484
1526
  }
1485
1527
  async publishTaskPlan(sessionId, taskState) {
1486
- await this.client.sessionUpdate({
1487
- sessionId,
1488
- update: {
1489
- sessionUpdate: "plan",
1490
- entries: taskStateToPlanEntries(taskState),
1491
- },
1492
- });
1528
+ const keyed = supportsPlanOperations(this.clientCapabilities);
1529
+ // An emptied list (a conversation reset) removes the keyed plan rather
1530
+ // than leaving an empty one on screen; a legacy client gets the empty plan
1531
+ // it always got.
1532
+ const update = keyed && taskState.size === 0
1533
+ ? planRemovedUpdate()
1534
+ : planSessionUpdate(taskStateToPlanEntries(taskState), keyed);
1535
+ await this.client.sessionUpdate({ sessionId, update });
1493
1536
  }
1494
1537
  /** This session's quota-window mapper, created on first use (see
1495
1538
  * {@link Session.accountUsage} for why it lives on the Session). */
@@ -1702,6 +1745,44 @@ export class ClaudeAcpAgent {
1702
1745
  log(refreshed ? "published" : "nothing", coalesced ? "coalesced" : undefined);
1703
1746
  return { refreshed };
1704
1747
  }
1748
+ /** Upstream #1464: a session notice when a client-provided MCP server reports `failed` or
1749
+ * `needs-auth` at init, once per server per session. Plus has no MCP OAuth flow, so a
1750
+ * `needs-auth` server is always reported; its tools stay unavailable until it is authenticated. */
1751
+ async reportMcpServerStartupFailures(session, servers, supportsNotices, sendUpdate, sessionId) {
1752
+ if (!servers?.length)
1753
+ return;
1754
+ const clientServers = new Set((session.creationParams?.mcpServers ?? []).map((server) => server.name));
1755
+ for (const server of servers) {
1756
+ if (!clientServers.has(server.name) || session.reportedMcpServers?.has(server.name)) {
1757
+ continue;
1758
+ }
1759
+ let notice;
1760
+ if (server.status === "failed") {
1761
+ notice = {
1762
+ severity: "warning",
1763
+ title: `MCP server "${server.name}" failed to start`,
1764
+ description: "Its tools are unavailable in this session. Run /mcp for the error.",
1765
+ };
1766
+ }
1767
+ else if (server.status === "needs-auth") {
1768
+ notice = {
1769
+ severity: "info",
1770
+ title: `MCP server "${server.name}" needs authentication`,
1771
+ description: "Its tools are unavailable until the server is authenticated.",
1772
+ };
1773
+ }
1774
+ else {
1775
+ continue;
1776
+ }
1777
+ (session.reportedMcpServers ??= new Set()).add(server.name);
1778
+ await sendUpdate({
1779
+ sessionId,
1780
+ update: noticeOrTranscriptUpdate(notice, supportsNotices, undefined, {
1781
+ claudeCode: { kind: "mcpServerStatus", server: server.name, status: server.status },
1782
+ }),
1783
+ });
1784
+ }
1785
+ }
1705
1786
  async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
1706
1787
  const goalUpdate = goalUpdateFromPrompt(prompt);
1707
1788
  if (goalUpdate !== undefined) {
@@ -1948,6 +2029,11 @@ export class ClaudeAcpAgent {
1948
2029
  endTurnScope(session, turn, reason) {
1949
2030
  session.classifierGrants.clearOnce();
1950
2031
  this.finishFileChangeAudit(session, turn, reason);
2032
+ // Every cancel path settles through here. The turn's foreground tasks end
2033
+ // with it even when the CLI omits their closing event (F3, §5.3).
2034
+ if (reason === "cancelled" && session.taskFeed && session.taskFeedPublisher) {
2035
+ session.taskFeedPublisher.notify(session.taskFeed.onTurnCancelled());
2036
+ }
1951
2037
  }
1952
2038
  /** Publish the audit terminal for every turn path that did not reach the
1953
2039
  * report tool. The support flips the turn state synchronously before its
@@ -1986,6 +2072,10 @@ export class ClaudeAcpAgent {
1986
2072
  let lastAssistantTotalUsage = null;
1987
2073
  let lastAssistantUsage = null;
1988
2074
  let lastAssistantModel = null;
2075
+ // A `rate_limit_event` that arrived before the turn had a context total to
2076
+ // send it with (upstream #1534): with partial messages on, the SDK sends it
2077
+ // ahead of the first assistant frame. It rides the turn's next usage_update.
2078
+ let pendingRateLimit = null;
1989
2079
  // When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
1990
2080
  // problem, billing), it sets a categorical `error` field on the
1991
2081
  // `SDKAssistantMessage` that precedes the final `result` message. We capture
@@ -2141,10 +2231,23 @@ export class ClaudeAcpAgent {
2141
2231
  await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
2142
2232
  };
2143
2233
  const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
2234
+ /** Attach a pending rate limit to a usage_update, once. A `_claude/rateLimit`
2235
+ * the update already carries wins, the same precedence upstream uses. */
2236
+ const withPendingRateLimit = (update) => {
2237
+ if (pendingRateLimit === null)
2238
+ return update;
2239
+ const rateLimit = pendingRateLimit;
2240
+ pendingRateLimit = null;
2241
+ return { ...update, _meta: { "_claude/rateLimit": rateLimit, ...(update._meta ?? {}) } };
2242
+ };
2144
2243
  const resetTurnScratch = () => {
2145
2244
  lastAssistantTotalUsage = null;
2146
2245
  lastAssistantUsage = null;
2147
2246
  lastAssistantModel = null;
2247
+ // A pending event belongs to the turn that measured it; one that found no
2248
+ // usage_update to ride must not be attributed to the next turn. The
2249
+ // tracker already remembers its status (R1.6).
2250
+ pendingRateLimit = null;
2148
2251
  lastAssistantError = undefined;
2149
2252
  lastAssistantWasUsageLimit = false;
2150
2253
  lastAssistantFailureTitle = undefined;
@@ -3051,6 +3154,7 @@ export class ClaudeAcpAgent {
3051
3154
  }
3052
3155
  }
3053
3156
  }
3157
+ await this.reportMcpServerStartupFailures(session, message.mcp_servers, supportsNotices, sendUpdate, params.sessionId);
3054
3158
  break;
3055
3159
  case "status": {
3056
3160
  // Compaction is reported as ONE tool lifecycle rather than the
@@ -3110,11 +3214,11 @@ export class ClaudeAcpAgent {
3110
3214
  session.contextUsedTokens = usedTokens ?? 0;
3111
3215
  await sendUpdate({
3112
3216
  sessionId: params.sessionId,
3113
- update: {
3217
+ update: withPendingRateLimit({
3114
3218
  sessionUpdate: "usage_update",
3115
3219
  used: lastAssistantTotalUsage,
3116
3220
  size: session.contextWindowSize,
3117
- },
3221
+ }),
3118
3222
  });
3119
3223
  break;
3120
3224
  }
@@ -3474,9 +3578,10 @@ export class ClaudeAcpAgent {
3474
3578
  }
3475
3579
  }
3476
3580
  const severity = message.level === "warning" ? "warning" : "info";
3581
+ const transcriptContent = collapseHookSpeaker(message.content);
3477
3582
  const transcriptText = message.level === "info"
3478
- ? message.content
3479
- : `**${sentenceCase(message.level)}:** ${message.content}`;
3583
+ ? transcriptContent
3584
+ : `**${sentenceCase(message.level)}:** ${transcriptContent}`;
3480
3585
  await sendUpdate({
3481
3586
  sessionId: params.sessionId,
3482
3587
  update: noticeOrTranscriptUpdate({
@@ -3530,6 +3635,10 @@ export class ClaudeAcpAgent {
3530
3635
  session.liveBackgroundTasks.set(message.task_id, {
3531
3636
  parentToolUseId: message.tool_use_id,
3532
3637
  isSubagent: !!message.subagent_type,
3638
+ ...(message.is_backgrounded !== undefined
3639
+ ? { backgrounded: message.is_backgrounded }
3640
+ : {}),
3641
+ ...(message.parent_task_id ? { parentTaskId: message.parent_task_id } : {}),
3533
3642
  });
3534
3643
  if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
3535
3644
  (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
@@ -3568,6 +3677,12 @@ export class ClaudeAcpAgent {
3568
3677
  this.logger.log(`[task/late-run] dropped=task_updated task=${message.task_id} run=${message.run_id}`);
3569
3678
  break;
3570
3679
  }
3680
+ // A task moved to the background no longer blocks the foreground (#1466).
3681
+ if (message.patch.is_backgrounded !== undefined) {
3682
+ const task = session.liveBackgroundTasks.get(message.task_id);
3683
+ if (task)
3684
+ task.backgrounded = message.patch.is_backgrounded;
3685
+ }
3571
3686
  // terminal-status task_updated patch and a (deduplicated)
3572
3687
  // task_notification when a task settles, but only the patch is
3573
3688
  // guaranteed per transition — prune on it too so the registry
@@ -3847,7 +3962,7 @@ export class ClaudeAcpAgent {
3847
3962
  message.stop_reason === "refusal" ||
3848
3963
  ("result" in message &&
3849
3964
  message.is_error &&
3850
- message.result.includes("Please run /login"));
3965
+ isLoginRequiredText(message.result));
3851
3966
  if (goalCommandFailed) {
3852
3967
  await this.publishGoal(params.sessionId, pendingGoalUpdate.previous ?? null);
3853
3968
  }
@@ -3973,7 +4088,7 @@ export class ClaudeAcpAgent {
3973
4088
  if (lastAssistantTotalUsage !== null) {
3974
4089
  await sendUpdate({
3975
4090
  sessionId: params.sessionId,
3976
- update: {
4091
+ update: withPendingRateLimit({
3977
4092
  sessionUpdate: "usage_update",
3978
4093
  used: lastAssistantTotalUsage,
3979
4094
  size: session.contextWindowSize,
@@ -3984,7 +4099,7 @@ export class ClaudeAcpAgent {
3984
4099
  ...(message.origin && {
3985
4100
  _meta: { "_claude/origin": message.origin },
3986
4101
  }),
3987
- },
4102
+ }),
3988
4103
  });
3989
4104
  }
3990
4105
  if (session.cancelled) {
@@ -4008,7 +4123,7 @@ export class ClaudeAcpAgent {
4008
4123
  // drained hold at their results is as good as the idle
4009
4124
  // fallback.) Then stop: everything below is user-turn
4010
4125
  // lifecycle, and an autonomous outcome must never touch it —
4011
- // its is_error or "Please run /login" text would otherwise
4126
+ // its is_error or "run /login" text would otherwise
4012
4127
  // failActive a live turn (the held one, or the user's next
4013
4128
  // prompt) whose own result recorded a different outcome.
4014
4129
  if (isAutonomousResult) {
@@ -4128,7 +4243,7 @@ export class ClaudeAcpAgent {
4128
4243
  }
4129
4244
  switch (message.subtype) {
4130
4245
  case "success": {
4131
- if (message.is_error && message.result.includes("Please run /login")) {
4246
+ if (message.is_error && isLoginRequiredText(message.result)) {
4132
4247
  await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
4133
4248
  break;
4134
4249
  }
@@ -4391,11 +4506,11 @@ export class ClaudeAcpAgent {
4391
4506
  session.contextUsedTokens = nextUsage;
4392
4507
  await sendUpdate({
4393
4508
  sessionId: params.sessionId,
4394
- update: {
4509
+ update: withPendingRateLimit({
4395
4510
  sessionUpdate: "usage_update",
4396
4511
  used: nextUsage,
4397
4512
  size: session.contextWindowSize,
4398
- },
4513
+ }),
4399
4514
  });
4400
4515
  }
4401
4516
  }
@@ -4532,6 +4647,13 @@ export class ClaudeAcpAgent {
4532
4647
  message.parent_tool_use_id === null &&
4533
4648
  message.message.model === "<synthetic>" &&
4534
4649
  compaction.hasDeliveredOutput) {
4650
+ if (isSyntheticUsageLimitMessage(message.message)) {
4651
+ lastAssistantWasUsageLimit = true;
4652
+ lastAssistantFailureTitle = assistantMessageText(message.message);
4653
+ if (message.error) {
4654
+ lastAssistantError = message.error;
4655
+ }
4656
+ }
4535
4657
  break;
4536
4658
  }
4537
4659
  // Snapshot the latest top-level assistant usage and model so the
@@ -4660,6 +4782,7 @@ export class ClaudeAcpAgent {
4660
4782
  }
4661
4783
  }
4662
4784
  let content;
4785
+ let toolUseResult = message.type === "user" ? message.tool_use_result : undefined;
4663
4786
  if (message.type === "assistant" && message.parent_tool_use_id === null) {
4664
4787
  // Top-level assistant message: each text/thinking block may have
4665
4788
  // already been streamed live as deltas. Diff each against what
@@ -4734,6 +4857,18 @@ export class ClaudeAcpAgent {
4734
4857
  // `parentToolUseId` stamped by toAcpNotifications.
4735
4858
  content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
4736
4859
  }
4860
+ else if (message.parent_tool_use_id !== null &&
4861
+ Array.isArray(message.message.content)) {
4862
+ // Upstream #1431 (#1389): a foreground fork inherits a placeholder result for its
4863
+ // own launch. It completed the Agent call and dropped its tracked state, so the
4864
+ // fork's real report, arriving later, was lost. Only the launching side completes
4865
+ // that call. The message-level sidecar cannot describe a remaining result once its
4866
+ // launch result is removed.
4867
+ const launchId = message.parent_tool_use_id;
4868
+ content = message.message.content.filter((block) => block.type !== "tool_result" || block.tool_use_id !== launchId);
4869
+ if (content.length !== message.message.content.length)
4870
+ toolUseResult = undefined;
4871
+ }
4737
4872
  else {
4738
4873
  content = message.message.content;
4739
4874
  }
@@ -4761,7 +4896,7 @@ export class ClaudeAcpAgent {
4761
4896
  taskState: session.taskState,
4762
4897
  emittedToolCalls: session.emittedToolCalls,
4763
4898
  messageId: messageIdForGrouping(message),
4764
- toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
4899
+ toolUseResult,
4765
4900
  // On the wire since CLI 2.1.216 but not in SDKUserMessage's
4766
4901
  // type, hence the cast. Validated by parseToolResultMeta.
4767
4902
  toolResultMeta: message.type === "user"
@@ -4846,12 +4981,15 @@ export class ClaudeAcpAgent {
4846
4981
  //
4847
4982
  // Recorded OUTSIDE the emission guard below, and before it: the
4848
4983
  // memory is fed by the event itself, not by our ability to forward
4849
- // it, and before the first assistant message of a session that
4850
- // guard is false. The forwarding itself is untouched (R1.8) — it
4851
- // still sends `rate_limit_info` verbatim, still on the same
4852
- // notification shape, still only when a context total exists.
4984
+ // it, and before the first assistant message of a turn that guard
4985
+ // is false. The forwarding still sends `rate_limit_info` verbatim on
4986
+ // the same notification shape (R1.8); when the turn has no context
4987
+ // total yet, it waits for the turn's next usage_update (#1534).
4853
4988
  this.accountUsageTracker(session).recordMeasured(message.rate_limit_info);
4854
- if (lastAssistantTotalUsage !== null) {
4989
+ if (lastAssistantTotalUsage === null) {
4990
+ pendingRateLimit = message.rate_limit_info;
4991
+ }
4992
+ else {
4855
4993
  await sendUpdate({
4856
4994
  sessionId: params.sessionId,
4857
4995
  update: {
@@ -5378,9 +5516,9 @@ export class ClaudeAcpAgent {
5378
5516
  // here the client has asked us to close the session, so signalling abort is
5379
5517
  // appropriate; query.close() above has already torn the subprocess down.
5380
5518
  session.abortController.abort();
5381
- // A pending throttle, retry or reconcile timer must not send to a closed
5382
- // session.
5383
- session.taskFeedPublisher?.dispose();
5519
+ // The publisher was disposed once, with its final flush, by
5520
+ // closeQueryStream → retireTaskFeed above (or when the stream closed
5521
+ // earlier). Disposing it again here used to drop that flush (F3).
5384
5522
  delete this.sessions[sessionId];
5385
5523
  }
5386
5524
  /** Tear down all active sessions. Called when the ACP connection closes. */
@@ -5510,16 +5648,6 @@ export class ClaudeAcpAgent {
5510
5648
  ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
5511
5649
  : [];
5512
5650
  let validValue = allValues.find((o) => o.value === params.value);
5513
- // The option's reported currentValue is always a valid target, even when
5514
- // it has no options entry: a session running an out-of-picker model
5515
- // (resumed onto an allowlist-excluded model, or a refusal fallback)
5516
- // reports a currentValue that isn't selectable, and a client
5517
- // round-tripping it must not get "Invalid value". It flows through the
5518
- // normal apply path below — re-asserting an already-current value is
5519
- // harmless and can repair SDK drift.
5520
- if (!validValue && option.currentValue === params.value) {
5521
- validValue = { value: params.value, name: params.value };
5522
- }
5523
5651
  // For model options, fall back to resolveModelPreference when the exact
5524
5652
  // value doesn't match. This lets callers use human-friendly aliases like
5525
5653
  // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
@@ -5703,7 +5831,7 @@ export class ClaudeAcpAgent {
5703
5831
  if (replaySession && replayMessageId && message.uuid) {
5704
5832
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
5705
5833
  }
5706
- // The live prompt loop converts the synthetic "Please run /login"
5834
+ // The live prompt loop converts the synthetic "run /login"
5707
5835
  // assistant message into an authRequired error instead of showing its
5708
5836
  // TUI-specific text; skip it on replay too (issue #863).
5709
5837
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
@@ -6056,6 +6184,21 @@ export class ClaudeAcpAgent {
6056
6184
  }
6057
6185
  if (signal.aborted)
6058
6186
  throw new Error("Tool use aborted");
6187
+ // On `session/cancel` the client answers every open permission request
6188
+ // `cancelled`, including those of a background subagent, which the
6189
+ // cancel does not stop. Answer such a request with a denial so the
6190
+ // subagent reads "the user refused" and goes on. Throwing instead makes
6191
+ // Claude Code deny it as "Tool permission request failed".
6192
+ if (response.outcome?.outcome === "cancelled" &&
6193
+ this.sessions[sessionId] === session &&
6194
+ !blocksForeground(session, agentID)) {
6195
+ return {
6196
+ behavior: "deny",
6197
+ message: "User refused permission to run tool",
6198
+ toolUseID,
6199
+ decisionClassification: "user_reject",
6200
+ };
6201
+ }
6059
6202
  const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
6060
6203
  let permissionResult = decodedPermission.permissionResult;
6061
6204
  const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
@@ -6103,18 +6246,20 @@ export class ClaudeAcpAgent {
6103
6246
  }
6104
6247
  /**
6105
6248
  * Handle elicitation requests that originate from MCP servers by forwarding
6106
- * them to the client over ACP. Modes the client did not advertise (or
6107
- * requests we can't represent) are declined.
6249
+ * them to the client over ACP. Only the user's own decline reaches the MCP
6250
+ * server as `decline`. A request the user never saw, because the client did
6251
+ * not advertise its mode, we can't represent it, or the client answered with
6252
+ * an error, is a `cancel`.
6108
6253
  */
6109
6254
  handleMcpElicitation(sessionId, support) {
6110
6255
  return async (request, { signal }) => {
6111
6256
  const isUrl = request.mode === "url";
6112
6257
  if ((isUrl && !support.url) || (!isUrl && !support.form)) {
6113
- return { action: "decline" };
6258
+ return { action: "cancel" };
6114
6259
  }
6115
6260
  const createRequest = mcpElicitationToCreateRequest(request, sessionId);
6116
6261
  if (!createRequest) {
6117
- return { action: "decline" };
6262
+ return { action: "cancel" };
6118
6263
  }
6119
6264
  try {
6120
6265
  const response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
@@ -6129,8 +6274,11 @@ export class ClaudeAcpAgent {
6129
6274
  if (signal.aborted) {
6130
6275
  return { action: "cancel" };
6131
6276
  }
6277
+ // The client answered with an error, e.g. -32602 for a form it cannot
6278
+ // render, so the user never saw the request. MCP's `decline` means the
6279
+ // user refused; `cancel` means they made no explicit choice.
6132
6280
  this.logger.error(`Failed to forward MCP elicitation: ${error}`);
6133
- return { action: "decline" };
6281
+ return { action: "cancel" };
6134
6282
  }
6135
6283
  };
6136
6284
  }
@@ -7188,6 +7336,7 @@ export class ClaudeAcpAgent {
7188
7336
  ...providerEnv,
7189
7337
  // Opt-in to session state events like when the agent is idle
7190
7338
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
7339
+ ...STARTUP_FAILURE_RESULTS_ENV,
7191
7340
  };
7192
7341
  // Plan updates (story 062): the todo tools are on unless an env the user
7193
7342
  // wrote -- this spawn env or the settings tier the CLI applies after it --
@@ -7308,6 +7457,30 @@ export class ClaudeAcpAgent {
7308
7457
  ],
7309
7458
  }
7310
7459
  : {}),
7460
+ // Upstream #1496: a subagent's working directory, from its own SubagentStart hook,
7461
+ // which Claude Code runs (and waits for) before the child's first model request.
7462
+ ...(this.childSessions
7463
+ ? {
7464
+ SubagentStart: [
7465
+ ...(userProvidedOptions?.hooks?.SubagentStart || []),
7466
+ {
7467
+ hooks: [
7468
+ async (input) => {
7469
+ const childSessions = this.childSessions;
7470
+ if (childSessions &&
7471
+ typeof input.agent_id === "string" &&
7472
+ typeof input.cwd === "string") {
7473
+ await childSessions
7474
+ .reportCwd(input.agent_id, input.cwd, params.cwd)
7475
+ .catch((error) => this.logger.error(`Session ${sessionId}: failed to report subagent cwd: ${error}`));
7476
+ }
7477
+ return { continue: true };
7478
+ },
7479
+ ],
7480
+ },
7481
+ ],
7482
+ }
7483
+ : {}),
7311
7484
  TaskCreated: [
7312
7485
  ...(userProvidedOptions?.hooks?.TaskCreated || []),
7313
7486
  {
@@ -7376,13 +7549,23 @@ export class ClaudeAcpAgent {
7376
7549
  initializationResult = await q.initializationResult();
7377
7550
  }
7378
7551
  catch (error) {
7552
+ // The CLI refused or exited: release the query so nothing of it is left behind (upstream
7553
+ // closes it here too).
7554
+ try {
7555
+ q.close();
7556
+ }
7557
+ catch (closeError) {
7558
+ this.logger.error(`Failed to close a query that did not initialize: ${closeError}`);
7559
+ }
7379
7560
  if (creationOpts.resume &&
7380
7561
  error instanceof Error &&
7381
7562
  (error.message === "Query closed before response received" ||
7382
7563
  error.message.includes("No conversation found with session ID"))) {
7383
7564
  throw RequestError.resourceNotFound(sessionId);
7384
7565
  }
7385
- throw error;
7566
+ // Upstream #1508: the CLI's own reason (bad proxy URL, wrong org sign-in, …) as an ACP
7567
+ // error, instead of "Claude Code process exited with code 1".
7568
+ throw startupFailureError(error) ?? error;
7386
7569
  }
7387
7570
  if (shouldHideClaudeAuth() &&
7388
7571
  initializationResult.account.subscriptionType &&
@@ -8019,6 +8202,21 @@ planUpdates) {
8019
8202
  }),
8020
8203
  },
8021
8204
  ];
8205
+ // Upstream #1463: ACP requires `currentValue` to be one of `options`
8206
+ // (agent-client-protocol#2557). A session can run a model the picker does not list --
8207
+ // resumed onto a model the `availableModels` allowlist excludes, or switched by a refusal
8208
+ // fallback -- so list it as its own entry. It is not added to `availableModels`, so the
8209
+ // next rebuild after switching to a listed model drops it.
8210
+ const modelOption = options[1];
8211
+ const modelRows = modelOption.options;
8212
+ if (!modelRows.some((row) => row.value === models.currentModelId)) {
8213
+ const info = modelInfos.find((model) => model.value === models.currentModelId);
8214
+ modelRows.push({
8215
+ value: models.currentModelId,
8216
+ name: info?.displayName || models.currentModelId,
8217
+ ...(info?.description ? { description: info.description } : {}),
8218
+ });
8219
+ }
8022
8220
  // Add effort level option based on the currently selected model
8023
8221
  const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
8024
8222
  const supportedLevels = currentModelInfo?.supportsEffort
@@ -8626,17 +8824,26 @@ resumedSessionId) {
8626
8824
  // not the raw catalog upstream maps here: the filter is visibility-only,
8627
8825
  // so preference resolution and capability lookups elsewhere keep reading
8628
8826
  // the unfiltered catalog (R4.3).
8629
- availableModels: pickerModels.map((model) => ({
8827
+ availableModels: withDistinctModelNames(pickerModels.map((model) => ({
8630
8828
  modelId: model.value,
8631
8829
  name: model.displayName,
8632
8830
  description: model.description,
8633
- })),
8831
+ }))),
8634
8832
  currentModelId: currentModel.value,
8635
8833
  },
8636
8834
  resumedContextWindow,
8637
8835
  resumedContextUsedTokens,
8638
8836
  };
8639
8837
  }
8838
+ /** Upstream #1286 (issue #1246): an allowlist can list an alias and pinned ids that inherit the
8839
+ * same SDK label, so a name-only picker shows identical rows. A name that occurs more than once
8840
+ * gets its model id appended; every row stays, and distinct names are unchanged. */
8841
+ export function withDistinctModelNames(models) {
8842
+ const counts = new Map();
8843
+ for (const model of models)
8844
+ counts.set(model.name, (counts.get(model.name) ?? 0) + 1);
8845
+ return models.map((model) => counts.get(model.name) === 1 ? model : { ...model, name: `${model.name} (${model.modelId})` });
8846
+ }
8640
8847
  function getAvailableSlashCommands(commands,
8641
8848
  // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
8642
8849
  // the CLI's own terminal, which ACP clients aren't) — filtered alongside
@@ -8962,6 +9169,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
8962
9169
  const taskState = options?.taskState ?? new Map();
8963
9170
  const registerHooks = options?.registerHooks !== false;
8964
9171
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
9172
+ // §5.1: the task list as a keyed plan when the client applies them.
9173
+ const keyedPlans = supportsPlanOperations(options?.clientCapabilities);
8965
9174
  if (typeof content === "string") {
8966
9175
  if (content.length === 0 || containsFileChangeAuditMarker(content)) {
8967
9176
  return [];
@@ -9063,10 +9272,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9063
9272
  else if (chunk.name === "TodoWrite") {
9064
9273
  // @ts-expect-error - sometimes input is empty object or undefined
9065
9274
  if (Array.isArray(chunk.input?.todos)) {
9066
- update = {
9067
- sessionUpdate: "plan",
9068
- entries: planEntries(chunk.input),
9069
- };
9275
+ update = planSessionUpdate(planEntries(chunk.input), keyedPlans);
9070
9276
  }
9071
9277
  }
9072
9278
  else if (isTaskTool(chunk.name)) {
@@ -9144,6 +9350,26 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9144
9350
  }
9145
9351
  break;
9146
9352
  }
9353
+ case "advisor_tool_result": {
9354
+ // Advisor results arrive in assistant content, including encrypted advice.
9355
+ // Resolve the server tool through the normal result path without rendering
9356
+ // opaque ciphertext. Server tools have no later PostToolUse hook to await.
9357
+ const result = chunk.content;
9358
+ unregisterHookCallback(chunk.tool_use_id);
9359
+ output.push(...toAcpNotifications([
9360
+ {
9361
+ type: "tool_result",
9362
+ tool_use_id: chunk.tool_use_id,
9363
+ is_error: result.type === "advisor_tool_result_error",
9364
+ content: result.type === "advisor_result"
9365
+ ? result.text
9366
+ : result.type === "advisor_redacted_result"
9367
+ ? "Advisor guidance applied server-side."
9368
+ : `Advisor error: ${result.error_code}`,
9369
+ },
9370
+ ], role, sessionId, toolUseCache, client, logger, options));
9371
+ break;
9372
+ }
9147
9373
  case "tool_result":
9148
9374
  case "tool_search_tool_result":
9149
9375
  case "web_fetch_tool_result":
@@ -9252,10 +9478,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9252
9478
  }
9253
9479
  }
9254
9480
  if (shouldEmitTaskPlan) {
9255
- update = {
9256
- sessionUpdate: "plan",
9257
- entries: taskStateToPlanEntries(taskState),
9258
- };
9481
+ update = planSessionUpdate(taskStateToPlanEntries(taskState), keyedPlans);
9259
9482
  }
9260
9483
  }
9261
9484
  else if (toolUse.name !== "TodoWrite") {
@@ -9316,7 +9539,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9316
9539
  case "container_upload":
9317
9540
  case "compaction":
9318
9541
  case "compaction_delta":
9319
- case "advisor_tool_result":
9320
9542
  case "fallback":
9321
9543
  case "mcp_tool_listing":
9322
9544
  break;