@lucascouts/claude-agent-acp-plus 0.32.0 → 0.34.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.
Files changed (66) hide show
  1. package/README.md +33 -3
  2. package/dist/accounts.d.ts +1 -1
  3. package/dist/accounts.d.ts.map +1 -1
  4. package/dist/acp-agent.d.ts +41 -2
  5. package/dist/acp-agent.d.ts.map +1 -1
  6. package/dist/acp-agent.js +387 -23
  7. package/dist/acp-subagents.d.ts +20 -0
  8. package/dist/acp-subagents.d.ts.map +1 -0
  9. package/dist/acp-subagents.js +14 -0
  10. package/dist/child-session-history.d.ts +26 -0
  11. package/dist/child-session-history.d.ts.map +1 -0
  12. package/dist/child-session-history.js +42 -0
  13. package/dist/child-sessions.d.ts +95 -0
  14. package/dist/child-sessions.d.ts.map +1 -0
  15. package/dist/child-sessions.js +247 -0
  16. package/dist/classifier-escalation.d.ts +1 -1
  17. package/dist/classifier-escalation.d.ts.map +1 -1
  18. package/dist/clear-context-coordinator.d.ts +1 -1
  19. package/dist/clear-context-coordinator.d.ts.map +1 -1
  20. package/dist/context-compaction.d.ts +1 -1
  21. package/dist/context-compaction.d.ts.map +1 -1
  22. package/dist/elicitation.d.ts +2 -2
  23. package/dist/elicitation.d.ts.map +1 -1
  24. package/dist/elicitation.js +1 -1
  25. package/dist/exit-plan.d.ts +1 -1
  26. package/dist/exit-plan.d.ts.map +1 -1
  27. package/dist/goal-extension.js +1 -1
  28. package/dist/permissions/effects.d.ts +1 -1
  29. package/dist/permissions/effects.d.ts.map +1 -1
  30. package/dist/permissions/options/filesystem.d.ts +1 -1
  31. package/dist/permissions/options/filesystem.d.ts.map +1 -1
  32. package/dist/permissions/options/shared.d.ts +1 -1
  33. package/dist/permissions/options/shared.d.ts.map +1 -1
  34. package/dist/permissions/options/shell.d.ts +1 -1
  35. package/dist/permissions/options/shell.d.ts.map +1 -1
  36. package/dist/permissions/options/tools.d.ts +1 -1
  37. package/dist/permissions/options/tools.d.ts.map +1 -1
  38. package/dist/permissions/options.d.ts +1 -1
  39. package/dist/permissions/options.d.ts.map +1 -1
  40. package/dist/permissions/presentation.d.ts +1 -1
  41. package/dist/permissions/presentation.d.ts.map +1 -1
  42. package/dist/permissions/response.d.ts +2 -2
  43. package/dist/permissions/response.d.ts.map +1 -1
  44. package/dist/plan-updates.d.ts +1 -1
  45. package/dist/plan-updates.d.ts.map +1 -1
  46. package/dist/session-failure-extension.d.ts +1 -1
  47. package/dist/session-failure-extension.d.ts.map +1 -1
  48. package/dist/session-mode.d.ts +1 -1
  49. package/dist/session-mode.d.ts.map +1 -1
  50. package/dist/session-mode.js +1 -1
  51. package/dist/session-notices.d.ts +1 -1
  52. package/dist/session-notices.d.ts.map +1 -1
  53. package/dist/session-titles.d.ts +1 -1
  54. package/dist/session-titles.d.ts.map +1 -1
  55. package/dist/task-feed.d.ts +3 -0
  56. package/dist/task-feed.d.ts.map +1 -1
  57. package/dist/thinking-option.d.ts +33 -5
  58. package/dist/thinking-option.d.ts.map +1 -1
  59. package/dist/thinking-option.js +47 -10
  60. package/dist/tools.d.ts +1 -1
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/ultracode.d.ts +1 -1
  63. package/dist/ultracode.d.ts.map +1 -1
  64. package/dist/usage-markdown.d.ts.map +1 -1
  65. package/dist/usage-markdown.js +74 -4
  66. package/package.json +4 -4
package/dist/acp-agent.js CHANGED
@@ -1,5 +1,5 @@
1
- import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
- import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
1
+ import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk/unstable";
2
+ import { deleteSession, getSessionMessages, getSubagentMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
4
4
  import { sanitizeTitle, SessionTitles } from "./session-titles.js";
5
5
  import { readResumedPermissionMode, readResumedSession, } from "./resumed-session.js";
@@ -18,7 +18,7 @@ import { filterDeprecatedModels } from "./model-deprecation.js";
18
18
  import { filterSupersededModels } from "./model-recency.js";
19
19
  import { SettingsManager } from "./settings.js";
20
20
  import { ContextCompactionLifecycle, contextCompactionMetadataFromBoundary, } from "./context-compaction.js";
21
- import { createThinkingConfigOption, effectiveThinkingConfig, resolveThinkingSelection, THINKING_CONFIG_ID, } from "./thinking-option.js";
21
+ import { createThinkingConfigOption, effectiveThinkingConfig, modelCanDisableThinking, resolveThinkingSelection, THINKING_CONFIG_ID, } from "./thinking-option.js";
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";
@@ -36,6 +36,9 @@ import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./u
36
36
  import { acceptedPlanToolResult, ExitPlanCoordinator, exitPlanModeRawOutput, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
37
37
  import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID, PLAN_UPDATES_CONFIG_ID, } from "./session-config-ids.js";
38
38
  import { withPrecompactHistory } from "./compacted-history.js";
39
+ import { subagentForm } from "./acp-subagents.js";
40
+ import { ChildSessionRouter, childStopReasonOf } from "./child-sessions.js";
41
+ import { readSubagentLaunches } from "./child-session-history.js";
39
42
  import { findTranscript } from "./transcript-path.js";
40
43
  import { parseToolResultMeta } from "./tool-result-meta.js";
41
44
  import { AccountUsageTracker } from "./account-usage.js";
@@ -157,6 +160,9 @@ export const TASKS_OUTPUT_METHOD = "_claude/tasks/output";
157
160
  export const TASKS_CAPABILITY_KEY = TASKS_META_KEY;
158
161
  /** Version 2 (story 041) adds `hasOutput`, `tail` and `_claude/tasks/output`. */
159
162
  export const TASKS_CAPABILITY_VERSION = TASKS_CONTRACT_VERSION;
163
+ /** Version 3: a subagent entry names its child session (`childSessionId`).
164
+ * Only a client on the subagents RFD gets it (story 065, R6.2, R6.3). */
165
+ export const TASKS_RFD_CONTRACT_VERSION = 3;
160
166
  /** Refresh the account quota windows of one session now, on demand. Params
161
167
  * {@link UsageRefreshRequest}, result {@link UsageRefreshResponse}. The windows
162
168
  * themselves arrive the way they always do — one `usage_update` per window,
@@ -400,6 +406,69 @@ function stripSubagentTextAndThinking(content) {
400
406
  }
401
407
  /** A cached tool call's `description` input, when it is a string — or, for a
402
408
  * shell call, its `command_description` alias (CLI 2.1.295+, upstream #1330). */
409
+ /** What a child session is announced with, read from `task_started` and the
410
+ * Agent call it names (story 065, R2.1): the Agent call's `name` or the
411
+ * subagent type as the title, its short description, and its prompt. */
412
+ function subagentLaunchOf(parentSessionId, session, message) {
413
+ const input = (session.toolUseCache[message.tool_use_id ?? ""]?.input ?? {});
414
+ const name = typeof input.name === "string" && input.name.trim() ? input.name : undefined;
415
+ const prompt = message.prompt ?? (typeof input.prompt === "string" ? input.prompt : undefined);
416
+ return {
417
+ parentSessionId,
418
+ launcherTaskId: message.parent_task_id,
419
+ toolUseId: message.tool_use_id,
420
+ agentId: message.task_id,
421
+ title: name ?? message.subagent_type ?? message.description,
422
+ description: message.description,
423
+ prompt,
424
+ cancellable: typeof session.query.stopTask === "function",
425
+ };
426
+ }
427
+ /** A child read back from a transcript, in the shape `ChildSessionRouter`
428
+ * announces: the same title rule as a live launch (`subagentLaunchOf`). */
429
+ function childLaunchOf(parentSessionId, session, toolUseId, record, input) {
430
+ const text = (value) => (typeof value === "string" && value.trim() ? value : undefined);
431
+ const description = record.description ?? text(input.description) ?? "";
432
+ return {
433
+ parentSessionId,
434
+ launcherTaskId: record.launcherAgentId,
435
+ toolUseId,
436
+ agentId: record.agentId,
437
+ title: record.name ?? text(input.name) ?? record.agentType ?? (description || "Subagent"),
438
+ description,
439
+ prompt: text(input.prompt),
440
+ // History, not a task the CLI holds: no Stop until it runs again, when its
441
+ // live launch advertises what the SDK can stop (design.md, Security).
442
+ cancellable: false,
443
+ };
444
+ }
445
+ /** The text of a user message whose content is a string or one text block. */
446
+ function userText(content) {
447
+ if (typeof content === "string")
448
+ return content;
449
+ if (Array.isArray(content) && content.length === 1 && content[0]?.type === "text") {
450
+ return content[0].text;
451
+ }
452
+ return undefined;
453
+ }
454
+ /** A child's permission request ended by `session/cancel` on that child. */
455
+ class ChildSessionCancelled extends Error {
456
+ constructor(childId) {
457
+ super(`Subagent ${childId} was cancelled`);
458
+ }
459
+ }
460
+ /** `client` with every `session/update` passed through the child router. */
461
+ function withChildRouting(client, router) {
462
+ return new Proxy(client, {
463
+ get(target, property) {
464
+ if (property === "sessionUpdate") {
465
+ return (notification) => target.sessionUpdate(router.route(notification));
466
+ }
467
+ const value = Reflect.get(target, property, target);
468
+ return typeof value === "function" ? value.bind(target) : value;
469
+ },
470
+ });
471
+ }
403
472
  function toolDescriptionOf(cache, toolUseId) {
404
473
  if (typeof toolUseId !== "string")
405
474
  return undefined;
@@ -772,6 +841,12 @@ export class ClaudeAcpAgent {
772
841
  sessions;
773
842
  client;
774
843
  clientCapabilities;
844
+ /** Set at `initialize`: `rfd` when the client declared `clientCapabilities.subagents`. */
845
+ subagentForm;
846
+ /** Present only in the RFD form: moves subagent traffic to child sessions (story 065). */
847
+ childSessions;
848
+ /** Open permission requests of each child, aborted when that child is cancelled. */
849
+ childAsks = new Map();
775
850
  logger;
776
851
  sessionModes;
777
852
  gatewayAuthRequest;
@@ -843,6 +918,16 @@ export class ClaudeAcpAgent {
843
918
  }
844
919
  async initialize(request) {
845
920
  this.clientCapabilities = request.clientCapabilities;
921
+ this.subagentForm = subagentForm(request.clientCapabilities);
922
+ if (this.subagentForm === "rfd" && !this.childSessions) {
923
+ const direct = this.client;
924
+ const router = new ChildSessionRouter((notification) => direct.sessionUpdate(notification), (line) => this.logger.log(line));
925
+ this.childSessions = router;
926
+ // The one place every `session/update` crosses — the consumer's
927
+ // `sendUpdate`, the task feed, hooks, the classifier — so a subagent's
928
+ // update cannot reach the parent by an emission site that forgot it.
929
+ this.client = withChildRouting(direct, router);
930
+ }
846
931
  // Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
847
932
  // Only offered when the client advertises `auth._meta.gateway` capability.
848
933
  const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
@@ -945,7 +1030,9 @@ export class ClaudeAcpAgent {
945
1030
  claudeCode: {
946
1031
  promptQueueing: true,
947
1032
  },
948
- [TASKS_CAPABILITY_KEY]: { version: TASKS_CAPABILITY_VERSION },
1033
+ [TASKS_CAPABILITY_KEY]: {
1034
+ version: this.childSessions ? TASKS_RFD_CONTRACT_VERSION : TASKS_CAPABILITY_VERSION,
1035
+ },
949
1036
  [USAGE_CAPABILITY_KEY]: { version: USAGE_CAPABILITY_VERSION },
950
1037
  },
951
1038
  promptCapabilities: {
@@ -1030,9 +1117,11 @@ export class ClaudeAcpAgent {
1030
1117
  return response;
1031
1118
  }
1032
1119
  async resumeSession(params) {
1120
+ this.refuseChildSession(params.sessionId, "lifecycle");
1033
1121
  if (this.providerUpdate)
1034
1122
  await this.providerUpdate;
1035
1123
  const result = await this.getOrCreateSession(params);
1124
+ await this.restoreChildSessions(params.sessionId);
1036
1125
  // Needs to happen after we return the session
1037
1126
  setTimeout(() => {
1038
1127
  this.sendAvailableCommandsUpdate(params.sessionId);
@@ -1040,6 +1129,7 @@ export class ClaudeAcpAgent {
1040
1129
  return result;
1041
1130
  }
1042
1131
  async loadSession(params) {
1132
+ this.refuseChildSession(params.sessionId, "lifecycle");
1043
1133
  if (this.providerUpdate)
1044
1134
  await this.providerUpdate;
1045
1135
  const result = await this.getOrCreateSession(params);
@@ -1673,6 +1763,37 @@ export class ClaudeAcpAgent {
1673
1763
  * `session/prompt`. Without the opt-in, the existing detached `prompt()` and
1674
1764
  * `startedNewTurn` result are preserved for compatibility. */
1675
1765
  /** `_claude/tasks/stop`: stop a task the feed holds as running (R2.1, R2.2). */
1766
+ /** `_claude/tasks` v3: each subagent entry with a child session names it
1767
+ * (R6.1); every other entry, and every snapshot off the RFD, is the v2
1768
+ * object as is (R6.3, R6.4). */
1769
+ withChildSessionIds(snapshot) {
1770
+ const router = this.childSessions;
1771
+ if (!router)
1772
+ return snapshot;
1773
+ return {
1774
+ ...snapshot,
1775
+ tasks: snapshot.tasks.map((task) => task.type === "subagent" && router.isChild(task.id)
1776
+ ? { ...task, childSessionId: task.id }
1777
+ : task),
1778
+ };
1779
+ }
1780
+ /** A child session is the parent's: its config is read-only, and it is
1781
+ * loaded, resumed and closed with its parent (story 065, R5.1, R5.2). Checked
1782
+ * first, so an id that names no session keeps the error it has today. */
1783
+ refuseChildSession(sessionId, operation) {
1784
+ if (!this.childSessions?.isChild(sessionId))
1785
+ return;
1786
+ throw RequestError.invalidParams({ sessionId }, operation === "config"
1787
+ ? "subagent sessions are read-only and loaded with their parent"
1788
+ : "subagent sessions are loaded with their parent and cannot be loaded, resumed or closed alone");
1789
+ }
1790
+ /** A subagent task ended: its child, if it has one, goes idle (story 065, R2.4). */
1791
+ async settleChildSession(taskId, status) {
1792
+ const stopReason = childStopReasonOf(status);
1793
+ if (!stopReason || !this.childSessions?.isChild(taskId))
1794
+ return;
1795
+ await this.childSessions.settle(taskId, stopReason);
1796
+ }
1676
1797
  async stopTask(params) {
1677
1798
  const { sessionId, taskId } = params;
1678
1799
  return this.taskAction(TASKS_STOP_METHOD, sessionId, `taskId=${logValue(taskId)}`, async (session) => {
@@ -3239,6 +3360,17 @@ export class ClaudeAcpAgent {
3239
3360
  this.logger.error(`Session ${message.session_id}: failed to persist history: ${message.error}`);
3240
3361
  break;
3241
3362
  }
3363
+ case "session_writes_lost": {
3364
+ // SDK 0.3.317+: the CLI could not write its transcript or
3365
+ // ~/.claude.json because the disk (or quota) was full. Same
3366
+ // class as mirror_error — lost session data a human must act
3367
+ // on — so it is logged the same way; the wire is unchanged.
3368
+ this.logger.error(`Session ${message.session_id}: session writes lost (${message.error_code}): ${(message.lost ?? []).join(", ")}` +
3369
+ (message.transcript_session_ids?.length
3370
+ ? `; transcripts: ${message.transcript_session_ids.join(", ")}`
3371
+ : ""));
3372
+ break;
3373
+ }
3242
3374
  case "permission_denied": {
3243
3375
  // A tool call was auto-denied (by a rule, the classifier,
3244
3376
  // dontAsk mode, etc.) before running. The tool_use block was
@@ -3402,6 +3534,9 @@ export class ClaudeAcpAgent {
3402
3534
  if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
3403
3535
  (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
3404
3536
  }
3537
+ if (this.childSessions && message.subagent_type && message.tool_use_id) {
3538
+ await this.childSessions.launch(subagentLaunchOf(params.sessionId, session, message));
3539
+ }
3405
3540
  // The spawning tool call's own `description` labels a shell
3406
3541
  // task instead of its command line. Read it now: the cache
3407
3542
  // entry is pruned at `tool_result`, which for a background
@@ -3416,6 +3551,7 @@ export class ClaudeAcpAgent {
3416
3551
  // The task settled — no further tool calls can originate
3417
3552
  // from it, so its registry entry can be dropped.
3418
3553
  session.liveBackgroundTasks.delete(message.task_id);
3554
+ await this.settleChildSession(message.task_id, message.status);
3419
3555
  await endBackgroundTerminal(message.task_id, backgroundExitCode(message.status, message.summary));
3420
3556
  armDrainGrace();
3421
3557
  if (session.taskFeed && session.taskFeedPublisher) {
@@ -3441,6 +3577,7 @@ export class ClaudeAcpAgent {
3441
3577
  message.patch.status === "failed" ||
3442
3578
  message.patch.status === "killed") {
3443
3579
  session.liveBackgroundTasks.delete(message.task_id);
3580
+ await this.settleChildSession(message.task_id, message.patch.status);
3444
3581
  // Usually first: the notification with its summary follows
3445
3582
  // but is not guaranteed, so the terminal ends here.
3446
3583
  await endBackgroundTerminal(message.task_id, backgroundExitCode(message.patch.status, message.patch.error));
@@ -4378,7 +4515,11 @@ export class ClaudeAcpAgent {
4378
4515
  break;
4379
4516
  }
4380
4517
  }
4381
- if (session.cancelled) {
4518
+ // A parent cancel keeps its background subagents running (story
4519
+ // 065, R3.2): in the RFD form their stream goes on to their child
4520
+ // sessions even while the parent's cancelled turn drains.
4521
+ if (session.cancelled &&
4522
+ !this.childSessions?.childFor(params.sessionId, parentToolUseIdOf(message))) {
4382
4523
  break;
4383
4524
  }
4384
4525
  // Synthetic assistant frames carry the CLI's local-command output.
@@ -4581,7 +4722,11 @@ export class ClaudeAcpAgent {
4581
4722
  streamedBlocks.length = 0;
4582
4723
  }
4583
4724
  else if (message.type === "assistant" &&
4584
- !(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
4725
+ !(session.forwardSubagentText ||
4726
+ supportsSubagentTranscript(this.clientCapabilities) ||
4727
+ // A child session is the subagent's own transcript (story 065):
4728
+ // its text belongs there, never in the parent's feed.
4729
+ this.childSessions?.childFor(params.sessionId, message.parent_tool_use_id))) {
4585
4730
  // Legacy clients don't understand nested transcripts. Keep the
4586
4731
  // historical behavior for them: subagent text/thinking remains
4587
4732
  // internal to the tool call instead of leaking into the top-level
@@ -4833,8 +4978,59 @@ export class ClaudeAcpAgent {
4833
4978
  }
4834
4979
  }
4835
4980
  async cancel(params) {
4981
+ if (this.childSessions?.isChild(params.sessionId)) {
4982
+ await this.cancelChildSession(params.sessionId);
4983
+ return;
4984
+ }
4836
4985
  await this.cancelTurns(params, { awaitInterrupt: true });
4837
4986
  }
4987
+ /** Register an ask (permission or question) of `childId`, aborted when that
4988
+ * child is cancelled; already aborted when it was. No-op for the parent. */
4989
+ openChildAsk(childId) {
4990
+ const controller = new AbortController();
4991
+ if (!childId)
4992
+ return controller;
4993
+ if (this.childSessions.isCancelled(childId)) {
4994
+ controller.abort();
4995
+ return controller;
4996
+ }
4997
+ let asks = this.childAsks.get(childId);
4998
+ if (!asks)
4999
+ this.childAsks.set(childId, (asks = new Set()));
5000
+ asks.add(controller);
5001
+ return controller;
5002
+ }
5003
+ closeChildAsk(childId, controller) {
5004
+ if (childId)
5005
+ this.childAsks.get(childId)?.delete(controller);
5006
+ }
5007
+ /** `session/cancel` naming a child stops that subagent alone: its SDK task,
5008
+ * never the parent's query, and its open questions are answered denied
5009
+ * (story 065, R3.1, R3.3). On a failed stop the state stays as reported. */
5010
+ async cancelChildSession(childId) {
5011
+ const parentId = this.childSessions.parentOf(childId);
5012
+ const session = this.sessions[parentId];
5013
+ if (!session) {
5014
+ // The parent is gone, and its query with it: nothing is left to stop.
5015
+ this.logger.log(`[subagents/cancel] outcome=parent_gone sessionId=${logValue(parentId)} childSessionId=${logValue(childId)}`);
5016
+ await this.childSessions.settle(childId, "cancelled");
5017
+ return;
5018
+ }
5019
+ // Join a query recreate, as `cancelTurns` does, so the stop reaches the live query.
5020
+ if (session.queryRecreateInFlight)
5021
+ await session.queryRecreateInFlight;
5022
+ try {
5023
+ await session.query.stopTask(childId);
5024
+ }
5025
+ catch (error) {
5026
+ this.logger.log(`[subagents/cancel] outcome=failed level=warn sessionId=${logValue(parentId)} childSessionId=${logValue(childId)} reason=${logValue(errorMessage(error))}`);
5027
+ throw new Error(`Could not stop subagent ${childId}`, { cause: error });
5028
+ }
5029
+ for (const ask of this.childAsks.get(childId) ?? [])
5030
+ ask.abort();
5031
+ this.childAsks.delete(childId);
5032
+ await this.childSessions.settle(childId, "cancelled");
5033
+ }
4838
5034
  /** Cancel the session's turns and interrupt the SDK query. With
4839
5035
  * `awaitInterrupt: false` the interrupt is sent, but its reply is not
4840
5036
  * awaited. `teardownSession` uses that: it closes the query right after, and
@@ -5146,7 +5342,10 @@ export class ClaudeAcpAgent {
5146
5342
  { warn: (message) => this.logger.log(`${message} level=warn`) }, sessionId);
5147
5343
  const taskFeedPublisher = new TaskFeedPublisher(taskFeed, (snapshot) => this.client.sessionUpdate({
5148
5344
  sessionId,
5149
- update: { sessionUpdate: "session_info_update", _meta: { [TASKS_META_KEY]: snapshot } },
5345
+ update: {
5346
+ sessionUpdate: "session_info_update",
5347
+ _meta: { [TASKS_META_KEY]: this.withChildSessionIds(snapshot) },
5348
+ },
5150
5349
  }), undefined, (error) => this.logger.error(`[tasks/feed] sessionId=${logValue(sessionId)} outcome=send_failed reason=${logValue(errorMessage(error))}`));
5151
5350
  const tailPoller = new TailPoller(taskFeed, taskFeedPublisher, undefined, undefined, (line) => this.logger.log(line), sessionId);
5152
5351
  return { taskFeed, taskFeedPublisher, tailPoller };
@@ -5189,6 +5388,7 @@ export class ClaudeAcpAgent {
5189
5388
  await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
5190
5389
  }
5191
5390
  async closeSession(params) {
5391
+ this.refuseChildSession(params.sessionId, "lifecycle");
5192
5392
  if (!this.sessions[params.sessionId]) {
5193
5393
  throw new Error("Session not found");
5194
5394
  }
@@ -5207,6 +5407,7 @@ export class ClaudeAcpAgent {
5207
5407
  return {};
5208
5408
  }
5209
5409
  async setSessionMode(params) {
5410
+ this.refuseChildSession(params.sessionId, "config");
5210
5411
  // A lazy Thinking recreate may be swapping the session's query right now;
5211
5412
  // join it (mirroring prompt()) so `setPermissionMode` below lands on the
5212
5413
  // replacement query after cutover instead of an about-to-close one — and
@@ -5222,6 +5423,7 @@ export class ClaudeAcpAgent {
5222
5423
  return this.sessionModes.setSessionMode(params);
5223
5424
  }
5224
5425
  async setSessionConfigOption(params) {
5426
+ this.refuseChildSession(params.sessionId, "config");
5225
5427
  const session = this.sessions[params.sessionId];
5226
5428
  if (!session) {
5227
5429
  throw new Error("Session not found");
@@ -5284,12 +5486,20 @@ export class ClaudeAcpAgent {
5284
5486
  // refreshed and acked immediately, mirroring Fast mode. An unrecognized
5285
5487
  // value deliberately falls through to the shared invalid-option-value
5286
5488
  // validation below rather than growing a bespoke error shape.
5489
+ //
5490
+ // An "off" on a model that cannot turn thinking off is still kept as the
5491
+ // intent rather than refused (story 071, R2.2): a client holding the option
5492
+ // list from before a model switch gets no error, the option reads "on"
5493
+ // there and the query runs adaptive (`effectiveThinkingConfig`), and the
5494
+ // retained Off holds again on a model that can honour it. (Zed itself
5495
+ // drops a saved default the option does not list, so it never sends one.)
5287
5496
  if (params.configId === THINKING_CONFIG_ID) {
5288
5497
  const enabled = resolveThinkingSelection(params.value);
5498
+ const canDisable = modelCanDisableThinking(session.modelInfos, session.models.currentModelId);
5289
5499
  if (enabled !== null) {
5290
5500
  session.thinkingEnabled = enabled;
5291
5501
  session.pendingQueryRecreate = true;
5292
- session.configOptions = session.configOptions.map((o) => o.id === THINKING_CONFIG_ID ? createThinkingConfigOption(enabled) : o);
5502
+ session.configOptions = session.configOptions.map((o) => o.id === THINKING_CONFIG_ID ? createThinkingConfigOption(enabled, canDisable) : o);
5293
5503
  return { configOptions: session.configOptions };
5294
5504
  }
5295
5505
  }
@@ -5356,8 +5566,99 @@ export class ClaudeAcpAgent {
5356
5566
  await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
5357
5567
  return { configOptions: session.configOptions };
5358
5568
  }
5569
+ /** A resumed parent's children, registered under their transcript ids but
5570
+ * not announced: a resume replays nothing, and a child the parent
5571
+ * continues is announced when it runs (story 065, R4.3). */
5572
+ async restoreChildSessions(sessionId) {
5573
+ if (!this.childSessions)
5574
+ return;
5575
+ const session = this.sessions[sessionId];
5576
+ const launches = await readSubagentLaunches(CLAUDE_CONFIG_DIR, sessionId);
5577
+ // Launchers before the subagents they launched, so each nests under its own.
5578
+ const byDepth = [...launches].sort(([, a], [, b]) => a.depth - b.depth);
5579
+ for (const [toolUseId, record] of byDepth) {
5580
+ await this.childSessions.restore(childLaunchOf(sessionId, session, toolUseId, record, {}), {
5581
+ announce: false,
5582
+ });
5583
+ }
5584
+ }
5585
+ /** Replay one child of a loaded parent right after the Agent call that
5586
+ * launched it: its announcement and prompt on the parent, then its own
5587
+ * transcript on its session (story 065, R4.1, R4.2). */
5588
+ async replayChildSession(parentId, toolUseId, input, record, launches, replayed) {
5589
+ // Each child once per load: a damaged meta naming a launch inside the
5590
+ // child's own transcript would otherwise recurse without end.
5591
+ if (replayed.has(record.agentId))
5592
+ return;
5593
+ replayed.add(record.agentId);
5594
+ const launch = childLaunchOf(parentId, this.sessions[parentId], toolUseId, record, input);
5595
+ await this.childSessions.restore(launch, { announce: true });
5596
+ if (this.childSessions.parentOf(record.agentId) !== parentId)
5597
+ return;
5598
+ let messages = [];
5599
+ let reason = "empty";
5600
+ try {
5601
+ messages = await getSubagentMessages(parentId, record.agentId);
5602
+ }
5603
+ catch (error) {
5604
+ reason = errorMessage(error);
5605
+ }
5606
+ if (messages.length === 0) {
5607
+ // The SDK answers a missing transcript with []: only the association replays.
5608
+ this.logger.log(`[subagents/replay] outcome=no_transcript level=warn sessionId=${logValue(parentId)} childSessionId=${logValue(record.agentId)} reason=${logValue(reason)}`);
5609
+ return;
5610
+ }
5611
+ const toolUseCache = {};
5612
+ let promptSkipped = false;
5613
+ for (const message of messages) {
5614
+ // @ts-expect-error - untyped in SDK but we handle all of these
5615
+ const role = message.message?.role;
5616
+ // @ts-expect-error - untyped in SDK but we handle all of these
5617
+ let content = message.message?.content;
5618
+ if (role === "user") {
5619
+ // The launch prompt already went out as the parent's session_message.
5620
+ if (!promptSkipped && launch.prompt && userText(content) === launch.prompt) {
5621
+ promptSkipped = true;
5622
+ continue;
5623
+ }
5624
+ content = stripLocalCommandMetadata(content);
5625
+ if (content === null)
5626
+ continue;
5627
+ }
5628
+ for (const notification of toAcpNotifications(
5629
+ // @ts-expect-error - untyped in SDK but we handle all of these
5630
+ content, role, parentId, toolUseCache, this.client, this.logger, {
5631
+ registerHooks: false,
5632
+ clientCapabilities: this.clientCapabilities,
5633
+ cwd: this.sessions[parentId]?.cwd,
5634
+ taskState: this.sessions[parentId]?.taskState,
5635
+ messageId: messageIdForGrouping(message),
5636
+ })) {
5637
+ await this.client.sessionUpdate({ ...notification, sessionId: record.agentId });
5638
+ }
5639
+ // A nested subagent's launch lives in its launcher's transcript.
5640
+ if (role === "assistant") {
5641
+ await this.replayLaunchesIn(parentId, content, launches, replayed);
5642
+ }
5643
+ }
5644
+ }
5645
+ /** Replay the children launched by the Agent calls in `content`. */
5646
+ async replayLaunchesIn(parentId, content, launches, replayed) {
5647
+ for (const block of Array.isArray(content) ? content : []) {
5648
+ const record = block?.type === "tool_use" && typeof block.id === "string"
5649
+ ? launches.get(block.id)
5650
+ : undefined;
5651
+ if (record) {
5652
+ await this.replayChildSession(parentId, block.id, block.input ?? {}, record, launches, replayed);
5653
+ }
5654
+ }
5655
+ }
5359
5656
  async replaySessionHistory(sessionId) {
5360
5657
  const toolUseCache = {};
5658
+ const childLaunches = this.childSessions
5659
+ ? await readSubagentLaunches(CLAUDE_CONFIG_DIR, sessionId)
5660
+ : undefined;
5661
+ const replayedChildren = new Set();
5361
5662
  // getSessionMessages stops at the last compaction boundary; put the history
5362
5663
  // before it back, so a reopened compacted thread scrolls to its first prompt.
5363
5664
  const messages = await withPrecompactHistory(sessionId, await getSessionMessages(sessionId), CLAUDE_CONFIG_DIR);
@@ -5500,6 +5801,9 @@ export class ClaudeAcpAgent {
5500
5801
  })) {
5501
5802
  await this.client.sessionUpdate(notification);
5502
5803
  }
5804
+ if (childLaunches?.size && message.type === "assistant" && parentToolUseId === null) {
5805
+ await this.replayLaunchesIn(sessionId, content, childLaunches, replayedChildren);
5806
+ }
5503
5807
  }
5504
5808
  }
5505
5809
  async readTextFile(params) {
@@ -5551,21 +5855,44 @@ export class ClaudeAcpAgent {
5551
5855
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
5552
5856
  // later refines it with a `tool_call_update` rather than emitting a
5553
5857
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
5858
+ // A child's ask is registered before anything awaits, so a cancel of
5859
+ // that child landing meanwhile still answers it (story 065, R3.3).
5860
+ const childId = this.childSessions?.childFor(params.sessionId, parentToolUseId);
5861
+ const childCancel = this.openChildAsk(childId);
5554
5862
  await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal);
5555
- if (signal.aborted)
5863
+ if (signal.aborted) {
5864
+ this.closeChildAsk(childId, childCancel);
5556
5865
  throw new Error("Tool use aborted");
5866
+ }
5557
5867
  // Do not rely on every ACP client settling requestPermission after the
5558
5868
  // cancellation signal. The local race guarantees that Claude's tool call
5559
5869
  // is released even when an older or broken client ignores $/cancel_request.
5870
+ const outgoing = childId ? { ...params, sessionId: childId } : params;
5871
+ const requestSignal = childId ? AbortSignal.any([signal, childCancel.signal]) : signal;
5872
+ if (childId) {
5873
+ if (childCancel.signal.aborted) {
5874
+ this.closeChildAsk(childId, childCancel);
5875
+ throw new ChildSessionCancelled(childId);
5876
+ }
5877
+ await this.childSessions.askOpened(childId);
5878
+ }
5560
5879
  try {
5561
- return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
5880
+ return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(outgoing, requestSignal), requestSignal));
5562
5881
  }
5563
5882
  catch (error) {
5564
5883
  if (signal.aborted) {
5565
5884
  throw new Error("Tool use aborted", { cause: error });
5566
5885
  }
5886
+ if (childCancel.signal.aborted)
5887
+ throw new ChildSessionCancelled(childId);
5567
5888
  throw error;
5568
5889
  }
5890
+ finally {
5891
+ if (childId) {
5892
+ this.closeChildAsk(childId, childCancel);
5893
+ await this.childSessions.askClosed(childId);
5894
+ }
5895
+ }
5569
5896
  }
5570
5897
  /** Emit the `tool_call` a permission request references if it hasn't been sent
5571
5898
  * yet, so the client has the tool call before being asked to approve it. The
@@ -5666,7 +5993,7 @@ export class ClaudeAcpAgent {
5666
5993
  // Like permission requests, the elicitation references this toolUseID, so
5667
5994
  // make sure the tool_call has surfaced to the client before we send it.
5668
5995
  await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal);
5669
- return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
5996
+ return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal, this.childSessions?.childFor(sessionId, parentToolUseId));
5670
5997
  }
5671
5998
  // Do not auto-allow here based on the session's advertised mode. Claude
5672
5999
  // Code applies bypassPermissions before invoking canUseTool; a request
@@ -5713,11 +6040,20 @@ export class ClaudeAcpAgent {
5713
6040
  ? undefined
5714
6041
  : Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
5715
6042
  });
5716
- const response = await this.requestPermissionFromClient({
5717
- ...presentation,
5718
- options: permissionOptions,
5719
- sessionId,
5720
- }, toolName, signal, parentToolUseId);
6043
+ let response;
6044
+ try {
6045
+ response = await this.requestPermissionFromClient({
6046
+ ...presentation,
6047
+ options: permissionOptions,
6048
+ sessionId,
6049
+ }, toolName, signal, parentToolUseId);
6050
+ }
6051
+ catch (error) {
6052
+ if (error instanceof ChildSessionCancelled) {
6053
+ return { behavior: "deny", message: "The subagent was cancelled." };
6054
+ }
6055
+ throw error;
6056
+ }
5721
6057
  if (signal.aborted)
5722
6058
  throw new Error("Tool use aborted");
5723
6059
  const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
@@ -5803,17 +6139,31 @@ export class ClaudeAcpAgent {
5803
6139
  * elicitation and return the answers as the tool's `updatedInput`. Called from
5804
6140
  * `canUseTool` since that is where the SDK routes the tool's permission check.
5805
6141
  */
5806
- async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal) {
6142
+ async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal,
6143
+ /** A subagent's question goes out on its child session (story 065, R2.3). */
6144
+ childId) {
5807
6145
  const questions = extractAskUserQuestions(toolInput);
5808
6146
  if (!questions) {
5809
6147
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
5810
6148
  }
5811
- const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
6149
+ const createRequest = askUserQuestionsToCreateRequest(questions, childId ?? sessionId, toolUseID);
5812
6150
  let response;
6151
+ const childCancel = this.openChildAsk(childId);
6152
+ if (childCancel.signal.aborted) {
6153
+ return { behavior: "deny", message: "The subagent was cancelled." };
6154
+ }
6155
+ const requestSignal = childId ? AbortSignal.any([signal, childCancel.signal]) : signal;
6156
+ if (childId)
6157
+ await this.childSessions?.askOpened(childId);
5813
6158
  try {
5814
- response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
6159
+ response = await this.withPendingUserInput(sessionId, () => childId
6160
+ ? raceWithAbort(this.client.unstable_createElicitation(createRequest, requestSignal), requestSignal)
6161
+ : this.client.unstable_createElicitation(createRequest, signal));
5815
6162
  }
5816
6163
  catch (error) {
6164
+ if (childCancel.signal.aborted && !signal.aborted) {
6165
+ return { behavior: "deny", message: "The subagent was cancelled." };
6166
+ }
5817
6167
  // A cancellation we requested (signal aborted) settles as an aborted tool
5818
6168
  // use, matching the post-response check below.
5819
6169
  if (signal.aborted) {
@@ -5822,6 +6172,11 @@ export class ClaudeAcpAgent {
5822
6172
  this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
5823
6173
  return { behavior: "deny", message: "Could not present the question to the user." };
5824
6174
  }
6175
+ finally {
6176
+ this.closeChildAsk(childId, childCancel);
6177
+ if (childId)
6178
+ await this.childSessions?.askClosed(childId);
6179
+ }
5825
6180
  if (signal.aborted) {
5826
6181
  throw new Error("Tool use aborted");
5827
6182
  }
@@ -5948,6 +6303,14 @@ export class ClaudeAcpAgent {
5948
6303
  // carries no "1m" token.
5949
6304
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
5950
6305
  const modelChanged = session.models.currentModelId !== value;
6306
+ // A retained Off is sent as `disabled` or as `adaptive` depending on the
6307
+ // model (story 071): when the switch changes which, the query must be
6308
+ // rebuilt at the next prompt, as a Thinking change itself would.
6309
+ if (session.thinkingEnabled === false &&
6310
+ modelCanDisableThinking(session.modelInfos, session.models.currentModelId) !==
6311
+ modelCanDisableThinking(session.modelInfos, value)) {
6312
+ session.pendingQueryRecreate = true;
6313
+ }
5951
6314
  if (modelChanged) {
5952
6315
  // Seed the new model's context window WITHOUT awaited IPC on the
5953
6316
  // switch path: cached authoritative value if we've already learned it
@@ -6237,7 +6600,7 @@ export class ClaudeAcpAgent {
6237
6600
  // tri-state intent beats the env var once set ("off" wins even with
6238
6601
  // MAX_THINKING_TOKENS present, R1.5); untouched sessions keep the
6239
6602
  // env-driven behavior (R1.6).
6240
- const thinking = effectiveThinkingConfig(session.thinkingEnabled, process.env.MAX_THINKING_TOKENS, this.logger);
6603
+ const thinking = effectiveThinkingConfig(session.thinkingEnabled, process.env.MAX_THINKING_TOKENS, this.logger, modelCanDisableThinking(session.modelInfos, session.models.currentModelId));
6241
6604
  // Snapshot of the live-tracked model/mode the replacement is built
6242
6605
  // with. A setter that was ALREADY awaiting its control request on the
6243
6606
  // old query when this recreate started (i.e. past its
@@ -7696,10 +8059,11 @@ planUpdates) {
7696
8059
  options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption, fastMode.disabledReason));
7697
8060
  }
7698
8061
  // Surface the Thinking toggle whenever the caller supplies its display
7699
- // state. Unlike Fast mode it is model-independent — no `supported` gate —
7700
- // and select-only ("on"/"off"), so every session shows it (R1.1).
8062
+ // state. Unlike Fast mode there is no `supported` gate — every session shows
8063
+ // it (R1.1) — but on a model that always thinks it offers "on" alone
8064
+ // (story 071, R2.2).
7701
8065
  if (thinkingEnabled !== undefined) {
7702
- options.push(createThinkingConfigOption(thinkingEnabled));
8066
+ options.push(createThinkingConfigOption(thinkingEnabled, modelCanDisableThinking(modelInfos, models.currentModelId)));
7703
8067
  }
7704
8068
  // Plan updates: model-independent, like Thinking (`plan-updates.ts`).
7705
8069
  if (planUpdates) {