@lucascouts/claude-agent-acp-plus 0.33.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.
package/dist/acp-agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk/unstable";
2
- import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
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";
@@ -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) => {
@@ -3413,6 +3534,9 @@ export class ClaudeAcpAgent {
3413
3534
  if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
3414
3535
  (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
3415
3536
  }
3537
+ if (this.childSessions && message.subagent_type && message.tool_use_id) {
3538
+ await this.childSessions.launch(subagentLaunchOf(params.sessionId, session, message));
3539
+ }
3416
3540
  // The spawning tool call's own `description` labels a shell
3417
3541
  // task instead of its command line. Read it now: the cache
3418
3542
  // entry is pruned at `tool_result`, which for a background
@@ -3427,6 +3551,7 @@ export class ClaudeAcpAgent {
3427
3551
  // The task settled — no further tool calls can originate
3428
3552
  // from it, so its registry entry can be dropped.
3429
3553
  session.liveBackgroundTasks.delete(message.task_id);
3554
+ await this.settleChildSession(message.task_id, message.status);
3430
3555
  await endBackgroundTerminal(message.task_id, backgroundExitCode(message.status, message.summary));
3431
3556
  armDrainGrace();
3432
3557
  if (session.taskFeed && session.taskFeedPublisher) {
@@ -3452,6 +3577,7 @@ export class ClaudeAcpAgent {
3452
3577
  message.patch.status === "failed" ||
3453
3578
  message.patch.status === "killed") {
3454
3579
  session.liveBackgroundTasks.delete(message.task_id);
3580
+ await this.settleChildSession(message.task_id, message.patch.status);
3455
3581
  // Usually first: the notification with its summary follows
3456
3582
  // but is not guaranteed, so the terminal ends here.
3457
3583
  await endBackgroundTerminal(message.task_id, backgroundExitCode(message.patch.status, message.patch.error));
@@ -4389,7 +4515,11 @@ export class ClaudeAcpAgent {
4389
4515
  break;
4390
4516
  }
4391
4517
  }
4392
- 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))) {
4393
4523
  break;
4394
4524
  }
4395
4525
  // Synthetic assistant frames carry the CLI's local-command output.
@@ -4592,7 +4722,11 @@ export class ClaudeAcpAgent {
4592
4722
  streamedBlocks.length = 0;
4593
4723
  }
4594
4724
  else if (message.type === "assistant" &&
4595
- !(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))) {
4596
4730
  // Legacy clients don't understand nested transcripts. Keep the
4597
4731
  // historical behavior for them: subagent text/thinking remains
4598
4732
  // internal to the tool call instead of leaking into the top-level
@@ -4844,8 +4978,59 @@ export class ClaudeAcpAgent {
4844
4978
  }
4845
4979
  }
4846
4980
  async cancel(params) {
4981
+ if (this.childSessions?.isChild(params.sessionId)) {
4982
+ await this.cancelChildSession(params.sessionId);
4983
+ return;
4984
+ }
4847
4985
  await this.cancelTurns(params, { awaitInterrupt: true });
4848
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
+ }
4849
5034
  /** Cancel the session's turns and interrupt the SDK query. With
4850
5035
  * `awaitInterrupt: false` the interrupt is sent, but its reply is not
4851
5036
  * awaited. `teardownSession` uses that: it closes the query right after, and
@@ -5157,7 +5342,10 @@ export class ClaudeAcpAgent {
5157
5342
  { warn: (message) => this.logger.log(`${message} level=warn`) }, sessionId);
5158
5343
  const taskFeedPublisher = new TaskFeedPublisher(taskFeed, (snapshot) => this.client.sessionUpdate({
5159
5344
  sessionId,
5160
- 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
+ },
5161
5349
  }), undefined, (error) => this.logger.error(`[tasks/feed] sessionId=${logValue(sessionId)} outcome=send_failed reason=${logValue(errorMessage(error))}`));
5162
5350
  const tailPoller = new TailPoller(taskFeed, taskFeedPublisher, undefined, undefined, (line) => this.logger.log(line), sessionId);
5163
5351
  return { taskFeed, taskFeedPublisher, tailPoller };
@@ -5200,6 +5388,7 @@ export class ClaudeAcpAgent {
5200
5388
  await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
5201
5389
  }
5202
5390
  async closeSession(params) {
5391
+ this.refuseChildSession(params.sessionId, "lifecycle");
5203
5392
  if (!this.sessions[params.sessionId]) {
5204
5393
  throw new Error("Session not found");
5205
5394
  }
@@ -5218,6 +5407,7 @@ export class ClaudeAcpAgent {
5218
5407
  return {};
5219
5408
  }
5220
5409
  async setSessionMode(params) {
5410
+ this.refuseChildSession(params.sessionId, "config");
5221
5411
  // A lazy Thinking recreate may be swapping the session's query right now;
5222
5412
  // join it (mirroring prompt()) so `setPermissionMode` below lands on the
5223
5413
  // replacement query after cutover instead of an about-to-close one — and
@@ -5233,6 +5423,7 @@ export class ClaudeAcpAgent {
5233
5423
  return this.sessionModes.setSessionMode(params);
5234
5424
  }
5235
5425
  async setSessionConfigOption(params) {
5426
+ this.refuseChildSession(params.sessionId, "config");
5236
5427
  const session = this.sessions[params.sessionId];
5237
5428
  if (!session) {
5238
5429
  throw new Error("Session not found");
@@ -5375,8 +5566,99 @@ export class ClaudeAcpAgent {
5375
5566
  await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
5376
5567
  return { configOptions: session.configOptions };
5377
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
+ }
5378
5656
  async replaySessionHistory(sessionId) {
5379
5657
  const toolUseCache = {};
5658
+ const childLaunches = this.childSessions
5659
+ ? await readSubagentLaunches(CLAUDE_CONFIG_DIR, sessionId)
5660
+ : undefined;
5661
+ const replayedChildren = new Set();
5380
5662
  // getSessionMessages stops at the last compaction boundary; put the history
5381
5663
  // before it back, so a reopened compacted thread scrolls to its first prompt.
5382
5664
  const messages = await withPrecompactHistory(sessionId, await getSessionMessages(sessionId), CLAUDE_CONFIG_DIR);
@@ -5519,6 +5801,9 @@ export class ClaudeAcpAgent {
5519
5801
  })) {
5520
5802
  await this.client.sessionUpdate(notification);
5521
5803
  }
5804
+ if (childLaunches?.size && message.type === "assistant" && parentToolUseId === null) {
5805
+ await this.replayLaunchesIn(sessionId, content, childLaunches, replayedChildren);
5806
+ }
5522
5807
  }
5523
5808
  }
5524
5809
  async readTextFile(params) {
@@ -5570,21 +5855,44 @@ export class ClaudeAcpAgent {
5570
5855
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
5571
5856
  // later refines it with a `tool_call_update` rather than emitting a
5572
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);
5573
5862
  await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal);
5574
- if (signal.aborted)
5863
+ if (signal.aborted) {
5864
+ this.closeChildAsk(childId, childCancel);
5575
5865
  throw new Error("Tool use aborted");
5866
+ }
5576
5867
  // Do not rely on every ACP client settling requestPermission after the
5577
5868
  // cancellation signal. The local race guarantees that Claude's tool call
5578
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
+ }
5579
5879
  try {
5580
- 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));
5581
5881
  }
5582
5882
  catch (error) {
5583
5883
  if (signal.aborted) {
5584
5884
  throw new Error("Tool use aborted", { cause: error });
5585
5885
  }
5886
+ if (childCancel.signal.aborted)
5887
+ throw new ChildSessionCancelled(childId);
5586
5888
  throw error;
5587
5889
  }
5890
+ finally {
5891
+ if (childId) {
5892
+ this.closeChildAsk(childId, childCancel);
5893
+ await this.childSessions.askClosed(childId);
5894
+ }
5895
+ }
5588
5896
  }
5589
5897
  /** Emit the `tool_call` a permission request references if it hasn't been sent
5590
5898
  * yet, so the client has the tool call before being asked to approve it. The
@@ -5685,7 +5993,7 @@ export class ClaudeAcpAgent {
5685
5993
  // Like permission requests, the elicitation references this toolUseID, so
5686
5994
  // make sure the tool_call has surfaced to the client before we send it.
5687
5995
  await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal);
5688
- return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
5996
+ return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal, this.childSessions?.childFor(sessionId, parentToolUseId));
5689
5997
  }
5690
5998
  // Do not auto-allow here based on the session's advertised mode. Claude
5691
5999
  // Code applies bypassPermissions before invoking canUseTool; a request
@@ -5732,11 +6040,20 @@ export class ClaudeAcpAgent {
5732
6040
  ? undefined
5733
6041
  : Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
5734
6042
  });
5735
- const response = await this.requestPermissionFromClient({
5736
- ...presentation,
5737
- options: permissionOptions,
5738
- sessionId,
5739
- }, 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
+ }
5740
6057
  if (signal.aborted)
5741
6058
  throw new Error("Tool use aborted");
5742
6059
  const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
@@ -5822,17 +6139,31 @@ export class ClaudeAcpAgent {
5822
6139
  * elicitation and return the answers as the tool's `updatedInput`. Called from
5823
6140
  * `canUseTool` since that is where the SDK routes the tool's permission check.
5824
6141
  */
5825
- 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) {
5826
6145
  const questions = extractAskUserQuestions(toolInput);
5827
6146
  if (!questions) {
5828
6147
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
5829
6148
  }
5830
- const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
6149
+ const createRequest = askUserQuestionsToCreateRequest(questions, childId ?? sessionId, toolUseID);
5831
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);
5832
6158
  try {
5833
- 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));
5834
6162
  }
5835
6163
  catch (error) {
6164
+ if (childCancel.signal.aborted && !signal.aborted) {
6165
+ return { behavior: "deny", message: "The subagent was cancelled." };
6166
+ }
5836
6167
  // A cancellation we requested (signal aborted) settles as an aborted tool
5837
6168
  // use, matching the post-response check below.
5838
6169
  if (signal.aborted) {
@@ -5841,6 +6172,11 @@ export class ClaudeAcpAgent {
5841
6172
  this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
5842
6173
  return { behavior: "deny", message: "Could not present the question to the user." };
5843
6174
  }
6175
+ finally {
6176
+ this.closeChildAsk(childId, childCancel);
6177
+ if (childId)
6178
+ await this.childSessions?.askClosed(childId);
6179
+ }
5844
6180
  if (signal.aborted) {
5845
6181
  throw new Error("Tool use aborted");
5846
6182
  }
@@ -0,0 +1,20 @@
1
+ import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
2
+ /**
3
+ * How a client gets native subagent sessions.
4
+ *
5
+ * `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
6
+ * as revised) — `subagent_update` and `session_message`, which the SDK types.
7
+ * Ported from upstream `src/acp-subagents.ts` without its `air` form: AIR's
8
+ * earlier draft of the RFD has no client in this chain (story 065, design.md).
9
+ */
10
+ export type SubagentForm = "rfd";
11
+ /** The form of native subagent sessions that the client gets, or none. */
12
+ export declare function subagentForm(capabilities?: ClientCapabilities | null): SubagentForm | undefined;
13
+ /**
14
+ * The schema's `SubagentCapabilities` is an object (`{}` or `{ _meta }`); any
15
+ * other shape is not a declaration. Zed drops `subagent_update` and
16
+ * `session_message` from a client that did not advertise it, so a false yes
17
+ * here loses the subagents entirely.
18
+ */
19
+ export declare function clientSupportsSubagents(capabilities?: ClientCapabilities | null): boolean;
20
+ //# sourceMappingURL=acp-subagents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"acp-subagents.d.ts","sourceRoot":"","sources":["../src/acp-subagents.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AAE5E;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,KAAK,CAAC;AAEjC,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,YAAY,CAAC,EAAE,kBAAkB,GAAG,IAAI,GAAG,YAAY,GAAG,SAAS,CAE/F;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,YAAY,CAAC,EAAE,kBAAkB,GAAG,IAAI,GAAG,OAAO,CAGzF"}
@@ -0,0 +1,14 @@
1
+ /** The form of native subagent sessions that the client gets, or none. */
2
+ export function subagentForm(capabilities) {
3
+ return clientSupportsSubagents(capabilities) ? "rfd" : undefined;
4
+ }
5
+ /**
6
+ * The schema's `SubagentCapabilities` is an object (`{}` or `{ _meta }`); any
7
+ * other shape is not a declaration. Zed drops `subagent_update` and
8
+ * `session_message` from a client that did not advertise it, so a false yes
9
+ * here loses the subagents entirely.
10
+ */
11
+ export function clientSupportsSubagents(capabilities) {
12
+ const subagents = capabilities?.subagents;
13
+ return typeof subagents === "object" && subagents !== null && !Array.isArray(subagents);
14
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Story 065, Task 4.1 — which Agent call launched which subagent, read back
3
+ * from a session's transcripts so a replayed or restored child keeps the id it
4
+ * had live (R4.1, R4.3).
5
+ *
6
+ * The bundled CLI writes, beside each subagent transcript
7
+ * `<session>/subagents/agent-<agentId>.jsonl`, a metadata file
8
+ * `agent-<agentId>.meta.json` whose `toolUseId` names the launching call
9
+ * (the same layout upstream's `subagent-history.ts` reads). The child id is
10
+ * the `<agentId>` of the file name — never generated: a launch without
11
+ * metadata has no known id and is left nested, as before.
12
+ */
13
+ export type SubagentLaunchRecord = {
14
+ agentId: string;
15
+ name?: string;
16
+ agentType?: string;
17
+ description?: string;
18
+ /** `parentAgentId`: the subagent whose agent launched this one (a nested
19
+ * subagent, whose launch is in that agent's transcript, not the parent's). */
20
+ launcherAgentId?: string;
21
+ /** `spawnDepth`: 1 for a launch by the main thread. */
22
+ depth: number;
23
+ };
24
+ /** The subagents of `sessionId`, keyed by the tool use id that launched each. */
25
+ export declare function readSubagentLaunches(configDir: string, sessionId: string): Promise<Map<string, SubagentLaunchRecord>>;
26
+ //# sourceMappingURL=child-session-history.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"child-session-history.d.ts","sourceRoot":"","sources":["../src/child-session-history.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;mFAC+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAIF,iFAAiF;AACjF,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAoC5C"}