@agentclientprotocol/claude-agent-acp 0.89.2-preview.9 → 0.90.1-preview.1

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 (52) hide show
  1. package/README.md +56 -8
  2. package/dist/acp-agent.d.ts +50 -1
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +493 -147
  5. package/dist/acp-subagents.d.ts +16 -6
  6. package/dist/acp-subagents.d.ts.map +1 -1
  7. package/dist/acp-subagents.js +8 -2
  8. package/dist/async-tasks.d.ts +4 -1
  9. package/dist/async-tasks.d.ts.map +1 -1
  10. package/dist/auth-status.d.ts +2 -2
  11. package/dist/auth-status.d.ts.map +1 -1
  12. package/dist/auth-status.js +18 -4
  13. package/dist/exit-plan.d.ts +17 -0
  14. package/dist/exit-plan.d.ts.map +1 -1
  15. package/dist/exit-plan.js +34 -0
  16. package/dist/native-subagents.d.ts +228 -11
  17. package/dist/native-subagents.d.ts.map +1 -1
  18. package/dist/native-subagents.js +489 -40
  19. package/dist/settings.d.ts +6 -0
  20. package/dist/settings.d.ts.map +1 -1
  21. package/dist/settings.js +13 -0
  22. package/dist/subagent-history.d.ts +44 -2
  23. package/dist/subagent-history.d.ts.map +1 -1
  24. package/dist/subagent-history.js +116 -10
  25. package/dist/subagent-replay.d.ts +108 -0
  26. package/dist/subagent-replay.d.ts.map +1 -0
  27. package/dist/subagent-replay.js +261 -0
  28. package/dist/tool-calls/background.d.ts +9 -0
  29. package/dist/tool-calls/background.d.ts.map +1 -1
  30. package/dist/tool-calls/background.js +25 -0
  31. package/dist/tool-calls/renderer.d.ts +30 -9
  32. package/dist/tool-calls/renderer.d.ts.map +1 -1
  33. package/dist/tool-calls/renderer.js +48 -22
  34. package/dist/tool-calls/reporters/bash.d.ts +7 -0
  35. package/dist/tool-calls/reporters/bash.d.ts.map +1 -1
  36. package/dist/tool-calls/reporters/bash.js +16 -1
  37. package/dist/tool-calls/reporters/search.d.ts.map +1 -1
  38. package/dist/tool-calls/reporters/search.js +20 -2
  39. package/dist/v2/agent.d.ts.map +1 -1
  40. package/dist/v2/agent.js +45 -12
  41. package/dist/v2/initialize.d.ts.map +1 -1
  42. package/dist/v2/initialize.js +10 -1
  43. package/dist/v2/session-update.d.ts +11 -0
  44. package/dist/v2/session-update.d.ts.map +1 -1
  45. package/dist/v2/session-update.js +67 -0
  46. package/dist/v2/session.d.ts +3 -12
  47. package/dist/v2/session.d.ts.map +1 -1
  48. package/dist/v2/session.js +5 -18
  49. package/dist/v2/tool-call-meta.d.ts +44 -0
  50. package/dist/v2/tool-call-meta.d.ts.map +1 -0
  51. package/dist/v2/tool-call-meta.js +72 -0
  52. package/package.json +9 -9
@@ -1,3 +1,4 @@
1
+ import { RequestError } from "@agentclientprotocol/sdk";
1
2
  import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
2
3
  const MAX_PENDING_PARENTS = 64;
3
4
  const MAX_PENDING_UPDATES = 256;
@@ -13,7 +14,10 @@ export class NativeSubagentRuntime {
13
14
  session;
14
15
  publish;
15
16
  logger;
17
+ lineage;
16
18
  enabled;
19
+ /** The form of the child sessions; see {@link SubagentForm}. */
20
+ form;
17
21
  children;
18
22
  taskByToolUse;
19
23
  parentByToolUse;
@@ -27,15 +31,23 @@ export class NativeSubagentRuntime {
27
31
  */
28
32
  childByToolCall = new Map();
29
33
  taskFinishPromises = new Map();
34
+ /** The RFD form: the session that made each SendMessage call. */
35
+ senderByToolUse = new Map();
30
36
  generationByTaskId = new Map();
31
37
  pending = new Map();
32
38
  pendingCount = 0;
33
- constructor(enabled, rootSessionId, session, publish, logger) {
39
+ /**
40
+ * `form` is undefined for a client without native subagent sessions. The RFD
41
+ * form restores an agent from before a load or resume with `lineage`.
42
+ */
43
+ constructor(form, rootSessionId, session, publish, logger, lineage) {
34
44
  this.rootSessionId = rootSessionId;
35
45
  this.session = session;
36
46
  this.publish = publish;
37
47
  this.logger = logger;
38
- this.enabled = enabled;
48
+ this.lineage = lineage;
49
+ this.form = form;
50
+ this.enabled = form !== undefined;
39
51
  this.children = session.nativeSubagentsByTaskId ??= new Map();
40
52
  this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
41
53
  this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
@@ -46,6 +58,12 @@ export class NativeSubagentRuntime {
46
58
  }
47
59
  }
48
60
  async route(notification, deliver, forcedSessionId) {
61
+ const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
62
+ if (routed && this.form === "rfd")
63
+ this.rememberSendMessage(routed);
64
+ return routed;
65
+ }
66
+ async routeUpdate(notification, deliver, forcedSessionId) {
49
67
  const { update } = notification;
50
68
  const claudeMeta = update._meta?.claudeCode;
51
69
  const isControl = isNativeSubagentControlUpdate(update);
@@ -60,28 +78,43 @@ export class NativeSubagentRuntime {
60
78
  if (identity) {
61
79
  this.identityByToolUse.set(toolCallId, mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity));
62
80
  }
81
+ const parentChild = claudeMeta?.parentToolUseId
82
+ ? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
83
+ : undefined;
63
84
  const parentSessionId = claudeMeta?.parentToolUseId
64
- ? this.childByParentToolUse.get(claudeMeta.parentToolUseId)?.sessionId
85
+ ? parentChild?.sessionId
65
86
  : this.rootSessionId;
66
- this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
87
+ // The RFD form: a call made inside a child that is not announced yet
88
+ // establishes no parent, because the RFD forbids announcing its child
89
+ // under the root or before that child. The update is buffered below
90
+ // under the child's tool use, and routed again, with the child as the
91
+ // parent, once the child is announced.
92
+ const parentPending = this.form === "rfd" && !!claudeMeta?.parentToolUseId && !parentChild?.announced;
67
93
  const child = this.childByParentToolUse.get(toolCallId);
68
- if (child && !child.announced) {
69
- child.parentSessionId = parentSessionId ?? this.rootSessionId;
70
- applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
71
- await announceNativeSubagent(child, this.publish);
72
- for (const pending of this.takePending(toolCallId))
73
- await deliver(pending);
94
+ if (!parentPending) {
95
+ this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
96
+ if (child && !child.announced) {
97
+ child.parentSessionId = parentSessionId ?? this.rootSessionId;
98
+ applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
99
+ await this.announce(child);
100
+ for (const pending of this.takePending(toolCallId))
101
+ await deliver(pending);
102
+ }
74
103
  }
75
- if (!child && isFailedToolCallUpdate(update)) {
76
- this.takePending(toolCallId);
77
- const initial = this.controlByToolUse.get(toolCallId);
78
- this.cleanupControl(toolCallId);
79
- if (forcedSessionId) {
80
- return { ...notification, sessionId: forcedSessionId };
104
+ if (this.form === "air") {
105
+ if (!child && isFailedToolCallUpdate(update)) {
106
+ this.takePending(toolCallId);
107
+ const initial = this.controlByToolUse.get(toolCallId);
108
+ this.cleanupControl(toolCallId);
109
+ if (forcedSessionId) {
110
+ return { ...notification, sessionId: forcedSessionId };
111
+ }
112
+ return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
81
113
  }
82
- return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
114
+ return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
83
115
  }
84
- return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
116
+ // The RFD form: the Agent/Task tool call is the parent's record of the
117
+ // delegation, so it stays a tool call of the session that made it.
85
118
  }
86
119
  const toolCallId = update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
87
120
  ? update.toolCallId
@@ -112,6 +145,16 @@ export class NativeSubagentRuntime {
112
145
  async taskStarted(task, deliver) {
113
146
  if (!this.enabled)
114
147
  return;
148
+ const known = this.form === "rfd"
149
+ ? (this.children.get(task.taskId) ??
150
+ (await this.restore(task.taskId, task.toolUseId ?? undefined, deliver)))
151
+ : undefined;
152
+ if (known) {
153
+ // A child keeps its session for every delegation to it. The SDK starts
154
+ // a resumed agent again under the id of the call that resumed it.
155
+ await this.resumeChild(known, task.taskId, promptText(task.prompt), task.toolUseId ?? undefined, deliver);
156
+ return;
157
+ }
115
158
  if (!task.subagentType) {
116
159
  if (task.toolUseId) {
117
160
  this.takePending(task.toolUseId);
@@ -135,6 +178,7 @@ export class NativeSubagentRuntime {
135
178
  name: subagentDisplayName(identity?.name, identity?.description ?? task.description, identity?.subagentType ?? task.subagentType, task.taskId),
136
179
  task: subagentDescription(identity?.prompt ?? task.prompt, identity?.description ?? task.description),
137
180
  ...promptField(promptText(task.prompt) ?? identity?.prompt),
181
+ ...rfdLabels(identity?.name, identity?.subagentType ?? task.subagentType, identity?.description ?? task.description),
138
182
  }, !!knownParentSessionId || !task.toolUseId, deliver);
139
183
  }
140
184
  /**
@@ -143,6 +187,10 @@ export class NativeSubagentRuntime {
143
187
  * running `task_updated` patch or a SendMessage `resumedAgentId` is the
144
188
  * signal. A child that did not finish is not changed. The `prompt` is the
145
189
  * SendMessage text that resumed the child, when the adapter knows it.
190
+ *
191
+ * In the RFD form, the running patch only reports that the child runs: its
192
+ * prompt is a guess (the latest SendMessage to the agent), and the
193
+ * `task_started` or the SendMessage result carries the delegation.
146
194
  */
147
195
  async taskResumed(taskId, deliver, prompt) {
148
196
  if (!this.enabled)
@@ -153,6 +201,10 @@ export class NativeSubagentRuntime {
153
201
  const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
154
202
  if (finishing)
155
203
  await finishing.catch(() => { });
204
+ if (this.form === "rfd") {
205
+ await this.resumeChild(previous, taskId, undefined, undefined, deliver);
206
+ return;
207
+ }
156
208
  if (this.children.get(taskId) !== previous || previous.terminalState === undefined)
157
209
  return;
158
210
  await this.openGeneration(taskId, previous, {
@@ -163,26 +215,97 @@ export class NativeSubagentRuntime {
163
215
  ...promptField(promptText(prompt)),
164
216
  }, true, deliver);
165
217
  }
166
- async finishTask(taskId, status, deliver, toolUseId) {
218
+ /**
219
+ * The SDK reports in a SendMessage result that the call `toolUseId`, with
220
+ * the message `prompt`, resumed the agent `taskId`. AIR's draft treats it as
221
+ * any resume signal. In the RFD form, the call is the delegation, which the
222
+ * child takes unless its run already ended: the result can arrive after it.
223
+ */
224
+ async sendMessageResumed(taskId, deliver, prompt, toolUseId) {
225
+ if (this.form !== "rfd")
226
+ return this.taskResumed(taskId, deliver, prompt);
227
+ const child = this.children.get(taskId) ?? (await this.restore(taskId, toolUseId, deliver));
228
+ if (!child)
229
+ return;
230
+ const finishing = this.taskFinishPromises.get(taskId);
231
+ if (finishing)
232
+ await finishing.catch(() => { });
233
+ if (this.isEndedDelegation(taskId, toolUseId))
234
+ return;
235
+ await this.resumeChild(child, taskId, promptText(prompt), toolUseId, deliver);
236
+ }
237
+ /**
238
+ * The RFD form: whether the run of the call `toolUseId` that delegated to
239
+ * the child `taskId` already ended. AIR's draft has no such runs.
240
+ */
241
+ isEndedDelegation(taskId, toolUseId) {
242
+ if (this.form !== "rfd" || !toolUseId)
243
+ return false;
244
+ return this.children.get(taskId)?.endedRuns?.has(toolUseId) ?? false;
245
+ }
246
+ /**
247
+ * The RFD form: whether `toolUseId` is an earlier delegation of the child
248
+ * `taskId`, whose end does not end the current one. AIR's draft has no
249
+ * such delegations.
250
+ */
251
+ isStaleEnd(taskId, toolUseId) {
252
+ if (this.form !== "rfd" || !toolUseId)
253
+ return false;
254
+ const child = this.children.get(taskId);
255
+ return (!!child &&
256
+ child.parentToolUseId !== undefined &&
257
+ toolUseId !== child.parentToolUseId &&
258
+ this.childByParentToolUse.get(toolUseId) === child);
259
+ }
260
+ /**
261
+ * Reports that the SDK ended the task's work. `end` has what the SDK says
262
+ * about it: its text for a failure, and its reason for an unusual end.
263
+ */
264
+ async finishTask(taskId, status, deliver, toolUseId, end) {
167
265
  if (!this.enabled)
168
266
  return;
169
267
  const state = nativeSubagentState(status);
170
268
  const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
171
269
  if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId)
172
270
  return;
173
- if (!state || !child || child.terminalState !== undefined)
271
+ if (!state || !child)
174
272
  return;
273
+ if (this.form === "rfd") {
274
+ if (this.isStaleEnd(taskId, toolUseId))
275
+ return;
276
+ if (!child.announced && !child.announcePromise) {
277
+ this.withdraw(taskId, child);
278
+ return;
279
+ }
280
+ }
175
281
  const existing = this.taskFinishPromises.get(taskId);
176
- if (existing)
177
- return existing;
282
+ if (existing) {
283
+ await existing;
284
+ if (this.form === "rfd")
285
+ await this.supplyError(child, state, end?.error);
286
+ return;
287
+ }
288
+ if (this.form === "air" ? child.terminalState !== undefined : child.workState === "idle") {
289
+ if (this.form === "rfd")
290
+ await this.supplyError(child, state, end?.error);
291
+ return;
292
+ }
178
293
  const finish = Promise.resolve().then(async () => {
179
294
  try {
180
- await announceNativeSubagent(child, this.publish);
295
+ await this.announce(child);
181
296
  if (child.parentToolUseId) {
182
297
  for (const pending of this.takePending(child.parentToolUseId))
183
298
  await deliver(pending);
184
299
  }
185
- await finishNativeSubagent(this.session, taskId, state, this.publish);
300
+ if (this.form === "rfd") {
301
+ await this.reportState(child, rfdEndState(state, end));
302
+ child.missingError = state === "failed" && !end?.error && !end?.unexplained;
303
+ if (child.parentToolUseId)
304
+ (child.endedRuns ??= new Set()).add(child.parentToolUseId);
305
+ }
306
+ else {
307
+ await finishNativeSubagent(this.session, taskId, state, this.publish);
308
+ }
186
309
  }
187
310
  finally {
188
311
  if (child.parentToolUseId) {
@@ -199,12 +322,20 @@ export class NativeSubagentRuntime {
199
322
  this.taskFinishPromises.delete(taskId);
200
323
  }
201
324
  }
325
+ /**
326
+ * Ends every child that still runs, because the stream ended or the
327
+ * conversation was reset. AIR's draft reports `state` for each. In the RFD
328
+ * form the cause of each child's end is unknown, so it is idle with no stop
329
+ * reason: a child that a cancel stopped already reported it from the SDK's
330
+ * events, and a background child that outlived the cancel was not cancelled.
331
+ */
202
332
  async finishAll(state, deliver) {
203
333
  const errors = [];
334
+ const end = this.form === "rfd" ? { unexplained: true } : undefined;
204
335
  try {
205
336
  for (const taskId of [...this.children.keys()].reverse()) {
206
337
  try {
207
- await this.finishTask(taskId, state, deliver);
338
+ await this.finishTask(taskId, state, deliver, undefined, end);
208
339
  }
209
340
  catch (error) {
210
341
  errors.push(error);
@@ -223,15 +354,182 @@ export class NativeSubagentRuntime {
223
354
  if (errors.length > 1)
224
355
  throw new AggregateError(errors, "Failed to finish native subagents");
225
356
  }
357
+ /**
358
+ * The parent session got `session/cancel`. AIR's draft ends every child as
359
+ * cancelled here. The RFD form reports nothing: acknowledging a cancel is no
360
+ * evidence that a child stopped. Claude Code's interrupt stops the
361
+ * foreground children that the turn waits on and, currently, background
362
+ * children too, and the SDK's task events report each as cancelled. A child
363
+ * that keeps running keeps its open requests
364
+ * (agentclientprotocol/agent-client-protocol#2308).
365
+ */
366
+ async parentCancelled(deliver) {
367
+ if (this.form === "air")
368
+ await this.finishAll("cancelled", deliver);
369
+ }
370
+ /**
371
+ * Runs `request`, a permission or question request of the session
372
+ * `sessionId`. In the RFD form, a child waiting on it is `requires_action`
373
+ * until its last open request ends.
374
+ */
375
+ async awaitingUser(sessionId, request) {
376
+ const child = this.form === "rfd" ? this.childOfSession(sessionId) : undefined;
377
+ if (!child?.announced)
378
+ return request();
379
+ child.openRequests = (child.openRequests ?? 0) + 1;
380
+ if (child.openRequests === 1 && child.workState === "running") {
381
+ await this.reportState(child, { state: "requires_action" }).catch((error) => this.logger.log(`Failed to report that subagent ${child.sessionId} waits: ${error}`));
382
+ }
383
+ try {
384
+ return await request();
385
+ }
386
+ finally {
387
+ child.openRequests = (child.openRequests ?? 1) - 1;
388
+ if (child.openRequests === 0 && child.workState === "requires_action") {
389
+ await this.reportState(child, { state: "running" }).catch((error) => this.logger.log(`Failed to report that subagent ${child.sessionId} runs: ${error}`));
390
+ }
391
+ }
392
+ }
226
393
  discardPending(parentToolUseId) {
227
394
  this.takePending(parentToolUseId);
228
395
  }
229
396
  /**
230
- * Routes an update to the child session. An update of a finished child is
231
- * dropped: it never goes to the root session.
397
+ * The RFD form: restores the agent `agentId` when the SendMessage call
398
+ * `toolUseId` resumes it and the runtime does not know it, because it ran
399
+ * before the session was loaded or resumed. A new launch is no resume: its
400
+ * call is an Agent or Task call.
401
+ *
402
+ * The agent keeps its session id from before, its agent id. The session's
403
+ * subagent history (see {@link SubagentLineage}) gives the call that
404
+ * launched it and its ancestors. The launch call is its delegation again:
405
+ * Claude Code sends the resumed run's messages under that call's id. Before the agent's traffic, the RFD requires its complete
406
+ * ancestor chain, parent before child. So each ancestor that the client was
407
+ * not told about in this connection is announced, with no work state since
408
+ * nothing says that it runs; then the agent, as running. The RFD forbids
409
+ * guessing a parent, so an agent that the history cannot place stays
410
+ * unexposed, as before.
411
+ */
412
+ async restore(agentId, toolUseId, deliver) {
413
+ if (!this.lineage || !toolUseId || !this.senderByToolUse.has(toolUseId))
414
+ return undefined;
415
+ const lineage = await this.lineage(agentId).catch((error) => {
416
+ this.logger.log(`Session ${this.rootSessionId}: failed to read the history of subagent ${agentId}: ${error}`);
417
+ return [];
418
+ });
419
+ for (const entry of lineage) {
420
+ let child = this.children.get(entry.agentId);
421
+ if (!child) {
422
+ child = {
423
+ sessionId: entry.agentId,
424
+ parentSessionId: entry.parentAgentId
425
+ ? (this.children.get(entry.parentAgentId)?.sessionId ?? entry.parentAgentId)
426
+ : this.rootSessionId,
427
+ parentToolUseId: entry.launchId,
428
+ name: subagentDisplayName(entry.name, entry.description, entry.agentType, entry.agentId),
429
+ task: subagentDescription(undefined, entry.description),
430
+ ...rfdLabels(entry.name, entry.agentType, entry.description),
431
+ };
432
+ this.children.set(entry.agentId, child);
433
+ this.taskByToolUse.set(entry.launchId, entry.agentId);
434
+ this.childByParentToolUse.set(entry.launchId, child);
435
+ }
436
+ if (child.announced)
437
+ continue;
438
+ if (entry.agentId === agentId) {
439
+ await this.announce(child);
440
+ }
441
+ else {
442
+ await this.publish({
443
+ sessionId: child.parentSessionId,
444
+ update: {
445
+ sessionUpdate: "subagent_update",
446
+ sessionId: child.sessionId,
447
+ ...(child.title ? { title: child.title } : {}),
448
+ ...(child.description ? { description: child.description } : {}),
449
+ },
450
+ });
451
+ child.announced = true;
452
+ }
453
+ for (const pending of this.takePending(entry.launchId))
454
+ await deliver(pending);
455
+ }
456
+ return this.children.get(agentId);
457
+ }
458
+ /**
459
+ * The RFD form: the child runs again, and keeps its session. `toolUseId` is
460
+ * the call that delegated, usually a SendMessage: the SDK runs the resumed
461
+ * work under its id, so it becomes the child's current delegation, whose
462
+ * end ends the run, and its prompt is the message with that id. A child
463
+ * that is already working only takes the delegation; a prompt already in
464
+ * its transcript is not sent again. A child that was never announced is
465
+ * not changed.
466
+ */
467
+ async resumeChild(child, taskId, prompt, toolUseId, deliver) {
468
+ if (!child.announced)
469
+ return;
470
+ if (toolUseId && toolUseId !== child.parentToolUseId) {
471
+ this.taskByToolUse.set(toolUseId, taskId);
472
+ this.childByParentToolUse.set(toolUseId, child);
473
+ child.parentToolUseId = toolUseId;
474
+ }
475
+ if (!isWorking(child)) {
476
+ await this.reportState(child, {
477
+ state: child.openRequests ? "requires_action" : "running",
478
+ });
479
+ }
480
+ if (prompt && toolUseId) {
481
+ await publishPrompt(child, prompt, toolUseId, this.senderByToolUse.get(toolUseId), this.publish);
482
+ }
483
+ for (const pending of toolUseId ? this.takePending(toolUseId) : [])
484
+ await deliver(pending);
485
+ }
486
+ /**
487
+ * The RFD form: a child that ends before its parent is known stays
488
+ * unexposed, since the RFD forbids guessing its parent. Its buffered updates
489
+ * are dropped, and it is forgotten, so a late frame does not announce it.
490
+ * (Buffering until that frame would also meet the RFD, but the frame comes
491
+ * before the task in the SDK's order, so a child ending without it is rare.)
492
+ */
493
+ withdraw(taskId, child) {
494
+ this.children.delete(taskId);
495
+ if (child.parentToolUseId) {
496
+ this.takePending(child.parentToolUseId);
497
+ this.cleanupControl(child.parentToolUseId);
498
+ this.childByParentToolUse.delete(child.parentToolUseId);
499
+ this.taskByToolUse.delete(child.parentToolUseId);
500
+ }
501
+ }
502
+ /**
503
+ * The RFD form: a later event of a failure can carry the SDK's error text
504
+ * that the reported idle snapshot lacks.
505
+ */
506
+ async supplyError(child, state, error) {
507
+ if (state !== "failed" || !error || !child.missingError)
508
+ return;
509
+ await this.reportState(child, rfdEndState("failed", { error }));
510
+ }
511
+ /** Announces `child` in the client's form. A runtime without one never does. */
512
+ async announce(child) {
513
+ if (this.form)
514
+ await announceNativeSubagent(child, this.publish, this.form);
515
+ }
516
+ /** The RFD form: publishes the work state of `child` on its parent. */
517
+ async reportState(child, state) {
518
+ child.workState = state.state;
519
+ child.missingError = false;
520
+ await this.publish({
521
+ sessionId: child.parentSessionId,
522
+ update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
523
+ });
524
+ }
525
+ /**
526
+ * Routes an update to the child session. It never goes to the root session:
527
+ * in AIR's draft, an update of a finished child is dropped.
232
528
  */
233
529
  toChild(child, notification, toolCallId) {
234
- if (child.terminalState !== undefined || child.terminalPromise) {
530
+ // AIR's draft ends a child for good. In the RFD form an idle child is
531
+ // reusable, and its background work can still report.
532
+ if (this.form === "air" && (child.terminalState !== undefined || child.terminalPromise)) {
235
533
  this.logger.log(`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`);
236
534
  return null;
237
535
  }
@@ -245,10 +543,11 @@ export class NativeSubagentRuntime {
245
543
  * tool call when the work started. An async task can outlive that child: a
246
544
  * subagent can start a background command and end its turn. So the route
247
545
  * still sends `async_task_spawned`, `async_task_progress` and
248
- * `async_task_state_update` to the child generation after it finished. The route drops every other update
249
- * after the child finished. `undefined` means that the root session owns the
250
- * tool call. `eagerSessionId` is the session where a permission request
251
- * created the tool call before the stream routed it.
546
+ * `async_task_state_update` to the child generation after it finished. In
547
+ * AIR's draft, the route drops every other update after the child finished;
548
+ * in the RFD form, an idle child gets them all. `undefined` means that the
549
+ * root session owns the tool call. `eagerSessionId` is the session where a
550
+ * permission request created the tool call before the stream routed it.
252
551
  */
253
552
  routeOfToolCall(toolCallId, eagerSessionId) {
254
553
  if (!this.enabled)
@@ -260,6 +559,25 @@ export class NativeSubagentRuntime {
260
559
  ? { ...notification, sessionId: owner.sessionId }
261
560
  : this.toChild(owner, notification, undefined)));
262
561
  }
562
+ /**
563
+ * The RFD form: remembers the session that made a SendMessage call, the
564
+ * sender of the prompt when the call resumes a child.
565
+ */
566
+ rememberSendMessage(notification) {
567
+ const { update } = notification;
568
+ if (update.sessionUpdate !== "tool_call")
569
+ return;
570
+ const claudeMeta = update._meta?.claudeCode;
571
+ if (claudeMeta?.toolName !== "SendMessage")
572
+ return;
573
+ this.senderByToolUse.delete(update.toolCallId);
574
+ this.senderByToolUse.set(update.toolCallId, notification.sessionId);
575
+ if (this.senderByToolUse.size > MAX_CHILD_TOOL_CALLS) {
576
+ const oldest = this.senderByToolUse.keys().next().value;
577
+ if (oldest !== undefined)
578
+ this.senderByToolUse.delete(oldest);
579
+ }
580
+ }
263
581
  rememberToolCallOwner(toolCallId, child) {
264
582
  this.childByToolCall.delete(toolCallId);
265
583
  this.childByToolCall.set(toolCallId, child);
@@ -288,6 +606,7 @@ export class NativeSubagentRuntime {
288
606
  this.controlByToolUse.clear();
289
607
  this.childByParentToolUse.clear();
290
608
  this.taskFinishPromises.clear();
609
+ this.senderByToolUse.clear();
291
610
  this.generationByTaskId.clear();
292
611
  this.pending.clear();
293
612
  this.pendingCount = 0;
@@ -353,10 +672,20 @@ export class NativeSubagentRuntime {
353
672
  }
354
673
  if (!announce)
355
674
  return;
356
- await announceNativeSubagent(child, this.publish);
675
+ await this.announce(child);
357
676
  for (const pending of toolUseId ? this.takePending(toolUseId) : [])
358
677
  await deliver(pending);
359
678
  }
679
+ /**
680
+ * The ACP session id of a child: the SDK agent id, and in AIR's draft a
681
+ * generation suffix for each resume. The RFD needs it unique within the
682
+ * connection and never under two parents. Agent ids are random, and an
683
+ * agent keeps its parent, so only a fork could repeat one under another
684
+ * root. That holds today because the SDK's `forkSession` copies no
685
+ * `subagents/` history, so a fork cannot resume an agent of the original.
686
+ * If a fork ever can, the RFD requires remapping the copied ids, for
687
+ * example by deriving the child id from the root session id.
688
+ */
360
689
  nextChildSessionId(taskId, previous) {
361
690
  if (!previous) {
362
691
  this.generationByTaskId.set(taskId, 1);
@@ -367,12 +696,37 @@ export class NativeSubagentRuntime {
367
696
  return `${taskId}:generation:${generation}`;
368
697
  }
369
698
  }
370
- export async function announceNativeSubagent(child, publish) {
699
+ /**
700
+ * Announces the child on its parent, once, before any update of the child.
701
+ * The RFD form announces it as running and then sends the prompt as the
702
+ * first message of the child. The child gets no capabilities, so the client
703
+ * may not cancel or otherwise change it.
704
+ */
705
+ export async function announceNativeSubagent(child, publish, form) {
371
706
  if (child.announced)
372
707
  return;
373
708
  if (child.announcePromise)
374
709
  return child.announcePromise;
375
710
  const announce = Promise.resolve().then(async () => {
711
+ if (form === "rfd") {
712
+ child.workState = "running";
713
+ await publish({
714
+ sessionId: child.parentSessionId,
715
+ update: {
716
+ sessionUpdate: "subagent_update",
717
+ sessionId: child.sessionId,
718
+ ...(child.title ? { title: child.title } : {}),
719
+ ...(child.description ? { description: child.description } : {}),
720
+ state: { state: "running" },
721
+ },
722
+ });
723
+ child.announced = true;
724
+ // The immediate parent made the Agent/Task call that launched the child.
725
+ if (child.prompt) {
726
+ await publishPrompt(child, child.prompt, child.parentToolUseId, child.parentSessionId, publish);
727
+ }
728
+ return;
729
+ }
376
730
  await publish({
377
731
  sessionId: child.parentSessionId,
378
732
  update: {
@@ -402,7 +756,7 @@ export async function finishNativeSubagent(session, taskId, state, publish) {
402
756
  if (child.terminalPromise)
403
757
  return child.terminalPromise;
404
758
  const finish = Promise.resolve().then(async () => {
405
- await announceNativeSubagent(child, publish);
759
+ await announceNativeSubagent(child, publish, "air");
406
760
  await publish({
407
761
  sessionId: child.parentSessionId,
408
762
  update: {
@@ -422,6 +776,85 @@ export async function finishNativeSubagent(session, taskId, state, publish) {
422
776
  child.terminalPromise = undefined;
423
777
  }
424
778
  }
779
+ /**
780
+ * The RFD form: a message to the child, in the child's transcript (its
781
+ * incoming view). Its id is the id of the tool use that sent it, the
782
+ * Agent/Task call or a SendMessage, so a prompt whose id is already in the
783
+ * transcript is the same delegation and is not sent again. `sender` is the
784
+ * session that made that call, when known.
785
+ */
786
+ async function publishPrompt(child, prompt, toolUseId, sender, publish) {
787
+ child.promptIds ??= new Set();
788
+ const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
789
+ if (child.promptIds.has(messageId))
790
+ return;
791
+ child.promptIds.add(messageId);
792
+ await publish({
793
+ sessionId: child.sessionId,
794
+ update: {
795
+ sessionUpdate: "session_message",
796
+ messageId,
797
+ ...(sender ? { senderSessionId: sender } : {}),
798
+ recipientSessionId: child.sessionId,
799
+ content: [{ type: "text", text: prompt }],
800
+ },
801
+ });
802
+ }
803
+ /** The RFD form: whether the child's foreground work is in progress. */
804
+ function isWorking(child) {
805
+ return child.workState === "running" || child.workState === "requires_action";
806
+ }
807
+ /**
808
+ * The RFD form: the work state at the end of a child's work.
809
+ *
810
+ * A failure is idle with the `error` stop reason and, when the SDK gives its
811
+ * text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
812
+ * response, so this is how a client learns that its work failed. The error is
813
+ * an internal error, as for a failed turn on the v2 surface.
814
+ *
815
+ * A task that a worker restart orphaned, or that was still running when the
816
+ * stream ended, is idle with no stop reason: its work ended, but the adapter
817
+ * did not see a cancellation or a failure, which the RFD forbids claiming.
818
+ */
819
+ export function rfdEndState(state, end) {
820
+ if (end?.unexplained || end?.reason === "worker_restart")
821
+ return { state: "idle" };
822
+ switch (state) {
823
+ case "completed":
824
+ return { state: "idle", stopReason: "end_turn" };
825
+ case "cancelled":
826
+ return { state: "idle", stopReason: "cancelled" };
827
+ case "failed":
828
+ return {
829
+ state: "idle",
830
+ stopReason: "error",
831
+ ...(end?.error
832
+ ? { error: { code: RequestError.internalError().code, message: end.error } }
833
+ : {}),
834
+ };
835
+ default:
836
+ // "disconnected": the child can no longer be observed.
837
+ return { state: "unknown" };
838
+ }
839
+ }
840
+ /**
841
+ * The RFD form: the parent's labels for a child. The title is the agent's
842
+ * name, with its role after it when both are known, else the role. The
843
+ * description is the task's short description, never the prompt.
844
+ */
845
+ export function rfdLabels(name, role, description) {
846
+ const agentName = nonBlankString(name);
847
+ const agentRole = nonBlankString(role);
848
+ const title = agentName && agentRole && agentName !== agentRole
849
+ ? `${agentName} (${agentRole})`
850
+ : (agentName ?? agentRole);
851
+ const text = nonBlankString(description);
852
+ return {
853
+ ...(title ? { title } : {}),
854
+ ...(agentRole ? { role: agentRole } : {}),
855
+ ...(text ? { description: text } : {}),
856
+ };
857
+ }
425
858
  /**
426
859
  * The agent id that a successful SendMessage result resumed. The SDK puts it
427
860
  * in `tool_use_result.resumedAgentId` when a finished agent runs again.
@@ -438,22 +871,34 @@ export function resumedNativeSubagentId(toolUseResult) {
438
871
  * carried the resume. Otherwise the latest SendMessage call to `agentId` counts.
439
872
  */
440
873
  export function sendMessageResumePrompt(toolUses, agentId, resultToolUseIds = []) {
874
+ return sendMessageResume(toolUses, agentId, resultToolUseIds)?.text;
875
+ }
876
+ /**
877
+ * The SendMessage call that resumed the agent `agentId`, and its text when it
878
+ * has one; see {@link sendMessageResumePrompt}. The call is known even
879
+ * without text, so a late result still names the delegation it reports.
880
+ */
881
+ export function sendMessageResume(toolUses, agentId, resultToolUseIds = []) {
882
+ let withoutText;
441
883
  for (const toolUseId of resultToolUseIds) {
442
884
  const toolUse = toolUses[toolUseId];
443
885
  if (toolUse?.name !== "SendMessage")
444
886
  continue;
445
887
  const text = promptText(toolUse.input?.message);
446
888
  if (text)
447
- return text;
889
+ return { toolUseId, text };
890
+ withoutText ??= toolUseId;
448
891
  }
449
892
  if (resultToolUseIds.length > 0)
450
- return undefined;
451
- for (const toolUse of Object.values(toolUses).reverse()) {
893
+ return withoutText ? { toolUseId: withoutText } : undefined;
894
+ for (const [toolUseId, toolUse] of Object.entries(toolUses).reverse()) {
452
895
  if (toolUse?.name !== "SendMessage")
453
896
  continue;
454
897
  const input = toolUse.input;
455
- if (input?.to === agentId)
456
- return promptText(input.message);
898
+ if (input?.to !== agentId)
899
+ continue;
900
+ const text = promptText(input.message);
901
+ return text ? { toolUseId, text } : { toolUseId };
457
902
  }
458
903
  return undefined;
459
904
  }
@@ -596,6 +1041,10 @@ function applySubagentIdentity(child, identity) {
596
1041
  child.task = subagentDescription(identity.prompt, identity.description);
597
1042
  }
598
1043
  child.prompt ??= identity.prompt;
1044
+ const labels = rfdLabels(identity.name, identity.subagentType ?? child.role, identity.description);
1045
+ child.title = labels.title ?? child.title;
1046
+ child.role = labels.role ?? child.role;
1047
+ child.description = labels.description ?? child.description;
599
1048
  }
600
1049
  /** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
601
1050
  function promptText(value) {