@agentclientprotocol/claude-agent-acp 0.89.2-preview.2 → 0.89.2-preview.20

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 +51 -8
  2. package/dist/acp-agent.d.ts +131 -23
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +879 -239
  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 +190 -11
  17. package/dist/native-subagents.d.ts.map +1 -1
  18. package/dist/native-subagents.js +419 -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 +27 -2
  23. package/dist/subagent-history.d.ts.map +1 -1
  24. package/dist/subagent-history.js +38 -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 +32 -7
  32. package/dist/tool-calls/renderer.d.ts.map +1 -1
  33. package/dist/tool-calls/renderer.js +54 -21
  34. package/dist/turn-events.d.ts +39 -17
  35. package/dist/turn-events.d.ts.map +1 -1
  36. package/dist/v2/agent.d.ts.map +1 -1
  37. package/dist/v2/agent.js +52 -14
  38. package/dist/v2/initialize.d.ts.map +1 -1
  39. package/dist/v2/initialize.js +10 -1
  40. package/dist/v2/prompt.d.ts +29 -18
  41. package/dist/v2/prompt.d.ts.map +1 -1
  42. package/dist/v2/prompt.js +54 -43
  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 +1 -1
@@ -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;
@@ -14,6 +15,8 @@ export class NativeSubagentRuntime {
14
15
  publish;
15
16
  logger;
16
17
  enabled;
18
+ /** The form of the child sessions; see {@link SubagentForm}. */
19
+ form;
17
20
  children;
18
21
  taskByToolUse;
19
22
  parentByToolUse;
@@ -27,15 +30,19 @@ export class NativeSubagentRuntime {
27
30
  */
28
31
  childByToolCall = new Map();
29
32
  taskFinishPromises = new Map();
33
+ /** The RFD form: the session that made each SendMessage call. */
34
+ senderByToolUse = new Map();
30
35
  generationByTaskId = new Map();
31
36
  pending = new Map();
32
37
  pendingCount = 0;
33
- constructor(enabled, rootSessionId, session, publish, logger) {
38
+ /** `form` is undefined for a client without native subagent sessions. */
39
+ constructor(form, rootSessionId, session, publish, logger) {
34
40
  this.rootSessionId = rootSessionId;
35
41
  this.session = session;
36
42
  this.publish = publish;
37
43
  this.logger = logger;
38
- this.enabled = enabled;
44
+ this.form = form;
45
+ this.enabled = form !== undefined;
39
46
  this.children = session.nativeSubagentsByTaskId ??= new Map();
40
47
  this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
41
48
  this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
@@ -46,6 +53,12 @@ export class NativeSubagentRuntime {
46
53
  }
47
54
  }
48
55
  async route(notification, deliver, forcedSessionId) {
56
+ const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
57
+ if (routed && this.form === "rfd")
58
+ this.rememberSendMessage(routed);
59
+ return routed;
60
+ }
61
+ async routeUpdate(notification, deliver, forcedSessionId) {
49
62
  const { update } = notification;
50
63
  const claudeMeta = update._meta?.claudeCode;
51
64
  const isControl = isNativeSubagentControlUpdate(update);
@@ -60,28 +73,43 @@ export class NativeSubagentRuntime {
60
73
  if (identity) {
61
74
  this.identityByToolUse.set(toolCallId, mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity));
62
75
  }
76
+ const parentChild = claudeMeta?.parentToolUseId
77
+ ? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
78
+ : undefined;
63
79
  const parentSessionId = claudeMeta?.parentToolUseId
64
- ? this.childByParentToolUse.get(claudeMeta.parentToolUseId)?.sessionId
80
+ ? parentChild?.sessionId
65
81
  : this.rootSessionId;
66
- this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
82
+ // The RFD form: a call made inside a child that is not announced yet
83
+ // establishes no parent, because the RFD forbids announcing its child
84
+ // under the root or before that child. The update is buffered below
85
+ // under the child's tool use, and routed again, with the child as the
86
+ // parent, once the child is announced.
87
+ const parentPending = this.form === "rfd" && !!claudeMeta?.parentToolUseId && !parentChild?.announced;
67
88
  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);
89
+ if (!parentPending) {
90
+ this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
91
+ if (child && !child.announced) {
92
+ child.parentSessionId = parentSessionId ?? this.rootSessionId;
93
+ applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
94
+ await this.announce(child);
95
+ for (const pending of this.takePending(toolCallId))
96
+ await deliver(pending);
97
+ }
74
98
  }
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 };
99
+ if (this.form === "air") {
100
+ if (!child && isFailedToolCallUpdate(update)) {
101
+ this.takePending(toolCallId);
102
+ const initial = this.controlByToolUse.get(toolCallId);
103
+ this.cleanupControl(toolCallId);
104
+ if (forcedSessionId) {
105
+ return { ...notification, sessionId: forcedSessionId };
106
+ }
107
+ return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
81
108
  }
82
- return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
109
+ return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
83
110
  }
84
- return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
111
+ // The RFD form: the Agent/Task tool call is the parent's record of the
112
+ // delegation, so it stays a tool call of the session that made it.
85
113
  }
86
114
  const toolCallId = update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
87
115
  ? update.toolCallId
@@ -112,6 +140,13 @@ export class NativeSubagentRuntime {
112
140
  async taskStarted(task, deliver) {
113
141
  if (!this.enabled)
114
142
  return;
143
+ const known = this.form === "rfd" ? this.children.get(task.taskId) : undefined;
144
+ if (known) {
145
+ // A child keeps its session for every delegation to it. The SDK starts
146
+ // a resumed agent again under the id of the call that resumed it.
147
+ await this.resumeChild(known, task.taskId, promptText(task.prompt), task.toolUseId ?? undefined, deliver);
148
+ return;
149
+ }
115
150
  if (!task.subagentType) {
116
151
  if (task.toolUseId) {
117
152
  this.takePending(task.toolUseId);
@@ -135,6 +170,7 @@ export class NativeSubagentRuntime {
135
170
  name: subagentDisplayName(identity?.name, identity?.description ?? task.description, identity?.subagentType ?? task.subagentType, task.taskId),
136
171
  task: subagentDescription(identity?.prompt ?? task.prompt, identity?.description ?? task.description),
137
172
  ...promptField(promptText(task.prompt) ?? identity?.prompt),
173
+ ...rfdLabels(identity?.name, identity?.subagentType ?? task.subagentType, identity?.description ?? task.description),
138
174
  }, !!knownParentSessionId || !task.toolUseId, deliver);
139
175
  }
140
176
  /**
@@ -143,6 +179,10 @@ export class NativeSubagentRuntime {
143
179
  * running `task_updated` patch or a SendMessage `resumedAgentId` is the
144
180
  * signal. A child that did not finish is not changed. The `prompt` is the
145
181
  * SendMessage text that resumed the child, when the adapter knows it.
182
+ *
183
+ * In the RFD form, the running patch only reports that the child runs: its
184
+ * prompt is a guess (the latest SendMessage to the agent), and the
185
+ * `task_started` or the SendMessage result carries the delegation.
146
186
  */
147
187
  async taskResumed(taskId, deliver, prompt) {
148
188
  if (!this.enabled)
@@ -153,6 +193,10 @@ export class NativeSubagentRuntime {
153
193
  const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
154
194
  if (finishing)
155
195
  await finishing.catch(() => { });
196
+ if (this.form === "rfd") {
197
+ await this.resumeChild(previous, taskId, undefined, undefined, deliver);
198
+ return;
199
+ }
156
200
  if (this.children.get(taskId) !== previous || previous.terminalState === undefined)
157
201
  return;
158
202
  await this.openGeneration(taskId, previous, {
@@ -163,26 +207,97 @@ export class NativeSubagentRuntime {
163
207
  ...promptField(promptText(prompt)),
164
208
  }, true, deliver);
165
209
  }
166
- async finishTask(taskId, status, deliver, toolUseId) {
210
+ /**
211
+ * The SDK reports in a SendMessage result that the call `toolUseId`, with
212
+ * the message `prompt`, resumed the agent `taskId`. AIR's draft treats it as
213
+ * any resume signal. In the RFD form, the call is the delegation, which the
214
+ * child takes unless its run already ended: the result can arrive after it.
215
+ */
216
+ async sendMessageResumed(taskId, deliver, prompt, toolUseId) {
217
+ if (this.form !== "rfd")
218
+ return this.taskResumed(taskId, deliver, prompt);
219
+ const child = this.children.get(taskId);
220
+ if (!child)
221
+ return;
222
+ const finishing = this.taskFinishPromises.get(taskId);
223
+ if (finishing)
224
+ await finishing.catch(() => { });
225
+ if (this.isEndedDelegation(taskId, toolUseId))
226
+ return;
227
+ await this.resumeChild(child, taskId, promptText(prompt), toolUseId, deliver);
228
+ }
229
+ /**
230
+ * The RFD form: whether the run of the call `toolUseId` that delegated to
231
+ * the child `taskId` already ended. AIR's draft has no such runs.
232
+ */
233
+ isEndedDelegation(taskId, toolUseId) {
234
+ if (this.form !== "rfd" || !toolUseId)
235
+ return false;
236
+ return this.children.get(taskId)?.endedRuns?.has(toolUseId) ?? false;
237
+ }
238
+ /**
239
+ * The RFD form: whether `toolUseId` is an earlier delegation of the child
240
+ * `taskId`, whose end does not end the current one. AIR's draft has no
241
+ * such delegations.
242
+ */
243
+ isStaleEnd(taskId, toolUseId) {
244
+ if (this.form !== "rfd" || !toolUseId)
245
+ return false;
246
+ const child = this.children.get(taskId);
247
+ return (!!child &&
248
+ child.parentToolUseId !== undefined &&
249
+ toolUseId !== child.parentToolUseId &&
250
+ this.childByParentToolUse.get(toolUseId) === child);
251
+ }
252
+ /**
253
+ * Reports that the SDK ended the task's work. `end` has what the SDK says
254
+ * about it: its text for a failure, and its reason for an unusual end.
255
+ */
256
+ async finishTask(taskId, status, deliver, toolUseId, end) {
167
257
  if (!this.enabled)
168
258
  return;
169
259
  const state = nativeSubagentState(status);
170
260
  const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
171
261
  if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId)
172
262
  return;
173
- if (!state || !child || child.terminalState !== undefined)
263
+ if (!state || !child)
174
264
  return;
265
+ if (this.form === "rfd") {
266
+ if (this.isStaleEnd(taskId, toolUseId))
267
+ return;
268
+ if (!child.announced && !child.announcePromise) {
269
+ this.withdraw(taskId, child);
270
+ return;
271
+ }
272
+ }
175
273
  const existing = this.taskFinishPromises.get(taskId);
176
- if (existing)
177
- return existing;
274
+ if (existing) {
275
+ await existing;
276
+ if (this.form === "rfd")
277
+ await this.supplyError(child, state, end?.error);
278
+ return;
279
+ }
280
+ if (this.form === "air" ? child.terminalState !== undefined : child.workState === "idle") {
281
+ if (this.form === "rfd")
282
+ await this.supplyError(child, state, end?.error);
283
+ return;
284
+ }
178
285
  const finish = Promise.resolve().then(async () => {
179
286
  try {
180
- await announceNativeSubagent(child, this.publish);
287
+ await this.announce(child);
181
288
  if (child.parentToolUseId) {
182
289
  for (const pending of this.takePending(child.parentToolUseId))
183
290
  await deliver(pending);
184
291
  }
185
- await finishNativeSubagent(this.session, taskId, state, this.publish);
292
+ if (this.form === "rfd") {
293
+ await this.reportState(child, rfdEndState(state, end));
294
+ child.missingError = state === "failed" && !end?.error && !end?.unexplained;
295
+ if (child.parentToolUseId)
296
+ (child.endedRuns ??= new Set()).add(child.parentToolUseId);
297
+ }
298
+ else {
299
+ await finishNativeSubagent(this.session, taskId, state, this.publish);
300
+ }
186
301
  }
187
302
  finally {
188
303
  if (child.parentToolUseId) {
@@ -199,12 +314,20 @@ export class NativeSubagentRuntime {
199
314
  this.taskFinishPromises.delete(taskId);
200
315
  }
201
316
  }
317
+ /**
318
+ * Ends every child that still runs, because the stream ended or the
319
+ * conversation was reset. AIR's draft reports `state` for each. In the RFD
320
+ * form the cause of each child's end is unknown, so it is idle with no stop
321
+ * reason: a child that a cancel stopped already reported it from the SDK's
322
+ * events, and a background child that outlived the cancel was not cancelled.
323
+ */
202
324
  async finishAll(state, deliver) {
203
325
  const errors = [];
326
+ const end = this.form === "rfd" ? { unexplained: true } : undefined;
204
327
  try {
205
328
  for (const taskId of [...this.children.keys()].reverse()) {
206
329
  try {
207
- await this.finishTask(taskId, state, deliver);
330
+ await this.finishTask(taskId, state, deliver, undefined, end);
208
331
  }
209
332
  catch (error) {
210
333
  errors.push(error);
@@ -223,15 +346,120 @@ export class NativeSubagentRuntime {
223
346
  if (errors.length > 1)
224
347
  throw new AggregateError(errors, "Failed to finish native subagents");
225
348
  }
349
+ /**
350
+ * The parent session got `session/cancel`. AIR's draft ends every child as
351
+ * cancelled here. The RFD form reports nothing: acknowledging a cancel is no
352
+ * evidence that a child stopped. Claude Code's interrupt stops the
353
+ * foreground children that the turn waits on and, currently, background
354
+ * children too, and the SDK's task events report each as cancelled. A child
355
+ * that keeps running keeps its open requests
356
+ * (agentclientprotocol/agent-client-protocol#2308).
357
+ */
358
+ async parentCancelled(deliver) {
359
+ if (this.form === "air")
360
+ await this.finishAll("cancelled", deliver);
361
+ }
362
+ /**
363
+ * Runs `request`, a permission or question request of the session
364
+ * `sessionId`. In the RFD form, a child waiting on it is `requires_action`
365
+ * until its last open request ends.
366
+ */
367
+ async awaitingUser(sessionId, request) {
368
+ const child = this.form === "rfd" ? this.childOfSession(sessionId) : undefined;
369
+ if (!child?.announced)
370
+ return request();
371
+ child.openRequests = (child.openRequests ?? 0) + 1;
372
+ if (child.openRequests === 1 && child.workState === "running") {
373
+ await this.reportState(child, { state: "requires_action" }).catch((error) => this.logger.log(`Failed to report that subagent ${child.sessionId} waits: ${error}`));
374
+ }
375
+ try {
376
+ return await request();
377
+ }
378
+ finally {
379
+ child.openRequests = (child.openRequests ?? 1) - 1;
380
+ if (child.openRequests === 0 && child.workState === "requires_action") {
381
+ await this.reportState(child, { state: "running" }).catch((error) => this.logger.log(`Failed to report that subagent ${child.sessionId} runs: ${error}`));
382
+ }
383
+ }
384
+ }
226
385
  discardPending(parentToolUseId) {
227
386
  this.takePending(parentToolUseId);
228
387
  }
229
388
  /**
230
- * Routes an update to the child session. An update of a finished child is
231
- * dropped: it never goes to the root session.
389
+ * The RFD form: the child runs again, and keeps its session. `toolUseId` is
390
+ * the call that delegated, usually a SendMessage: the SDK runs the resumed
391
+ * work under its id, so it becomes the child's current delegation, whose
392
+ * end ends the run, and its prompt is the message with that id. A child
393
+ * that is already working only takes the delegation; a prompt already in
394
+ * its transcript is not sent again. A child that was never announced is
395
+ * not changed.
396
+ */
397
+ async resumeChild(child, taskId, prompt, toolUseId, deliver) {
398
+ if (!child.announced)
399
+ return;
400
+ if (toolUseId && toolUseId !== child.parentToolUseId) {
401
+ this.taskByToolUse.set(toolUseId, taskId);
402
+ this.childByParentToolUse.set(toolUseId, child);
403
+ child.parentToolUseId = toolUseId;
404
+ }
405
+ if (!isWorking(child)) {
406
+ await this.reportState(child, {
407
+ state: child.openRequests ? "requires_action" : "running",
408
+ });
409
+ }
410
+ if (prompt && toolUseId) {
411
+ await publishPrompt(child, prompt, toolUseId, this.senderByToolUse.get(toolUseId), this.publish);
412
+ }
413
+ for (const pending of toolUseId ? this.takePending(toolUseId) : [])
414
+ await deliver(pending);
415
+ }
416
+ /**
417
+ * The RFD form: a child that ends before its parent is known stays
418
+ * unexposed, since the RFD forbids guessing its parent. Its buffered updates
419
+ * are dropped, and it is forgotten, so a late frame does not announce it.
420
+ * (Buffering until that frame would also meet the RFD, but the frame comes
421
+ * before the task in the SDK's order, so a child ending without it is rare.)
422
+ */
423
+ withdraw(taskId, child) {
424
+ this.children.delete(taskId);
425
+ if (child.parentToolUseId) {
426
+ this.takePending(child.parentToolUseId);
427
+ this.cleanupControl(child.parentToolUseId);
428
+ this.childByParentToolUse.delete(child.parentToolUseId);
429
+ this.taskByToolUse.delete(child.parentToolUseId);
430
+ }
431
+ }
432
+ /**
433
+ * The RFD form: a later event of a failure can carry the SDK's error text
434
+ * that the reported idle snapshot lacks.
435
+ */
436
+ async supplyError(child, state, error) {
437
+ if (state !== "failed" || !error || !child.missingError)
438
+ return;
439
+ await this.reportState(child, rfdEndState("failed", { error }));
440
+ }
441
+ /** Announces `child` in the client's form. A runtime without one never does. */
442
+ async announce(child) {
443
+ if (this.form)
444
+ await announceNativeSubagent(child, this.publish, this.form);
445
+ }
446
+ /** The RFD form: publishes the work state of `child` on its parent. */
447
+ async reportState(child, state) {
448
+ child.workState = state.state;
449
+ child.missingError = false;
450
+ await this.publish({
451
+ sessionId: child.parentSessionId,
452
+ update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
453
+ });
454
+ }
455
+ /**
456
+ * Routes an update to the child session. It never goes to the root session:
457
+ * in AIR's draft, an update of a finished child is dropped.
232
458
  */
233
459
  toChild(child, notification, toolCallId) {
234
- if (child.terminalState !== undefined || child.terminalPromise) {
460
+ // AIR's draft ends a child for good. In the RFD form an idle child is
461
+ // reusable, and its background work can still report.
462
+ if (this.form === "air" && (child.terminalState !== undefined || child.terminalPromise)) {
235
463
  this.logger.log(`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`);
236
464
  return null;
237
465
  }
@@ -245,10 +473,11 @@ export class NativeSubagentRuntime {
245
473
  * tool call when the work started. An async task can outlive that child: a
246
474
  * subagent can start a background command and end its turn. So the route
247
475
  * 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.
476
+ * `async_task_state_update` to the child generation after it finished. In
477
+ * AIR's draft, the route drops every other update after the child finished;
478
+ * in the RFD form, an idle child gets them all. `undefined` means that the
479
+ * root session owns the tool call. `eagerSessionId` is the session where a
480
+ * permission request created the tool call before the stream routed it.
252
481
  */
253
482
  routeOfToolCall(toolCallId, eagerSessionId) {
254
483
  if (!this.enabled)
@@ -260,6 +489,25 @@ export class NativeSubagentRuntime {
260
489
  ? { ...notification, sessionId: owner.sessionId }
261
490
  : this.toChild(owner, notification, undefined)));
262
491
  }
492
+ /**
493
+ * The RFD form: remembers the session that made a SendMessage call, the
494
+ * sender of the prompt when the call resumes a child.
495
+ */
496
+ rememberSendMessage(notification) {
497
+ const { update } = notification;
498
+ if (update.sessionUpdate !== "tool_call")
499
+ return;
500
+ const claudeMeta = update._meta?.claudeCode;
501
+ if (claudeMeta?.toolName !== "SendMessage")
502
+ return;
503
+ this.senderByToolUse.delete(update.toolCallId);
504
+ this.senderByToolUse.set(update.toolCallId, notification.sessionId);
505
+ if (this.senderByToolUse.size > MAX_CHILD_TOOL_CALLS) {
506
+ const oldest = this.senderByToolUse.keys().next().value;
507
+ if (oldest !== undefined)
508
+ this.senderByToolUse.delete(oldest);
509
+ }
510
+ }
263
511
  rememberToolCallOwner(toolCallId, child) {
264
512
  this.childByToolCall.delete(toolCallId);
265
513
  this.childByToolCall.set(toolCallId, child);
@@ -288,6 +536,7 @@ export class NativeSubagentRuntime {
288
536
  this.controlByToolUse.clear();
289
537
  this.childByParentToolUse.clear();
290
538
  this.taskFinishPromises.clear();
539
+ this.senderByToolUse.clear();
291
540
  this.generationByTaskId.clear();
292
541
  this.pending.clear();
293
542
  this.pendingCount = 0;
@@ -353,10 +602,20 @@ export class NativeSubagentRuntime {
353
602
  }
354
603
  if (!announce)
355
604
  return;
356
- await announceNativeSubagent(child, this.publish);
605
+ await this.announce(child);
357
606
  for (const pending of toolUseId ? this.takePending(toolUseId) : [])
358
607
  await deliver(pending);
359
608
  }
609
+ /**
610
+ * The ACP session id of a child: the SDK agent id, and in AIR's draft a
611
+ * generation suffix for each resume. The RFD needs it unique within the
612
+ * connection and never under two parents. Agent ids are random, and an
613
+ * agent keeps its parent, so only a fork could repeat one under another
614
+ * root. That holds today because the SDK's `forkSession` copies no
615
+ * `subagents/` history, so a fork cannot resume an agent of the original.
616
+ * If a fork ever can, the RFD requires remapping the copied ids, for
617
+ * example by deriving the child id from the root session id.
618
+ */
360
619
  nextChildSessionId(taskId, previous) {
361
620
  if (!previous) {
362
621
  this.generationByTaskId.set(taskId, 1);
@@ -367,12 +626,37 @@ export class NativeSubagentRuntime {
367
626
  return `${taskId}:generation:${generation}`;
368
627
  }
369
628
  }
370
- export async function announceNativeSubagent(child, publish) {
629
+ /**
630
+ * Announces the child on its parent, once, before any update of the child.
631
+ * The RFD form announces it as running and then sends the prompt as the
632
+ * first message of the child. The child gets no capabilities, so the client
633
+ * may not cancel or otherwise change it.
634
+ */
635
+ export async function announceNativeSubagent(child, publish, form) {
371
636
  if (child.announced)
372
637
  return;
373
638
  if (child.announcePromise)
374
639
  return child.announcePromise;
375
640
  const announce = Promise.resolve().then(async () => {
641
+ if (form === "rfd") {
642
+ child.workState = "running";
643
+ await publish({
644
+ sessionId: child.parentSessionId,
645
+ update: {
646
+ sessionUpdate: "subagent_update",
647
+ sessionId: child.sessionId,
648
+ ...(child.title ? { title: child.title } : {}),
649
+ ...(child.description ? { description: child.description } : {}),
650
+ state: { state: "running" },
651
+ },
652
+ });
653
+ child.announced = true;
654
+ // The immediate parent made the Agent/Task call that launched the child.
655
+ if (child.prompt) {
656
+ await publishPrompt(child, child.prompt, child.parentToolUseId, child.parentSessionId, publish);
657
+ }
658
+ return;
659
+ }
376
660
  await publish({
377
661
  sessionId: child.parentSessionId,
378
662
  update: {
@@ -402,7 +686,7 @@ export async function finishNativeSubagent(session, taskId, state, publish) {
402
686
  if (child.terminalPromise)
403
687
  return child.terminalPromise;
404
688
  const finish = Promise.resolve().then(async () => {
405
- await announceNativeSubagent(child, publish);
689
+ await announceNativeSubagent(child, publish, "air");
406
690
  await publish({
407
691
  sessionId: child.parentSessionId,
408
692
  update: {
@@ -422,6 +706,85 @@ export async function finishNativeSubagent(session, taskId, state, publish) {
422
706
  child.terminalPromise = undefined;
423
707
  }
424
708
  }
709
+ /**
710
+ * The RFD form: a message to the child, in the child's transcript (its
711
+ * incoming view). Its id is the id of the tool use that sent it, the
712
+ * Agent/Task call or a SendMessage, so a prompt whose id is already in the
713
+ * transcript is the same delegation and is not sent again. `sender` is the
714
+ * session that made that call, when known.
715
+ */
716
+ async function publishPrompt(child, prompt, toolUseId, sender, publish) {
717
+ child.promptIds ??= new Set();
718
+ const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
719
+ if (child.promptIds.has(messageId))
720
+ return;
721
+ child.promptIds.add(messageId);
722
+ await publish({
723
+ sessionId: child.sessionId,
724
+ update: {
725
+ sessionUpdate: "session_message",
726
+ messageId,
727
+ ...(sender ? { senderSessionId: sender } : {}),
728
+ recipientSessionId: child.sessionId,
729
+ content: [{ type: "text", text: prompt }],
730
+ },
731
+ });
732
+ }
733
+ /** The RFD form: whether the child's foreground work is in progress. */
734
+ function isWorking(child) {
735
+ return child.workState === "running" || child.workState === "requires_action";
736
+ }
737
+ /**
738
+ * The RFD form: the work state at the end of a child's work.
739
+ *
740
+ * A failure is idle with the `error` stop reason and, when the SDK gives its
741
+ * text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
742
+ * response, so this is how a client learns that its work failed. The error is
743
+ * an internal error, as for a failed turn on the v2 surface.
744
+ *
745
+ * A task that a worker restart orphaned, or that was still running when the
746
+ * stream ended, is idle with no stop reason: its work ended, but the adapter
747
+ * did not see a cancellation or a failure, which the RFD forbids claiming.
748
+ */
749
+ export function rfdEndState(state, end) {
750
+ if (end?.unexplained || end?.reason === "worker_restart")
751
+ return { state: "idle" };
752
+ switch (state) {
753
+ case "completed":
754
+ return { state: "idle", stopReason: "end_turn" };
755
+ case "cancelled":
756
+ return { state: "idle", stopReason: "cancelled" };
757
+ case "failed":
758
+ return {
759
+ state: "idle",
760
+ stopReason: "error",
761
+ ...(end?.error
762
+ ? { error: { code: RequestError.internalError().code, message: end.error } }
763
+ : {}),
764
+ };
765
+ default:
766
+ // "disconnected": the child can no longer be observed.
767
+ return { state: "unknown" };
768
+ }
769
+ }
770
+ /**
771
+ * The RFD form: the parent's labels for a child. The title is the agent's
772
+ * name, with its role after it when both are known, else the role. The
773
+ * description is the task's short description, never the prompt.
774
+ */
775
+ export function rfdLabels(name, role, description) {
776
+ const agentName = nonBlankString(name);
777
+ const agentRole = nonBlankString(role);
778
+ const title = agentName && agentRole && agentName !== agentRole
779
+ ? `${agentName} (${agentRole})`
780
+ : (agentName ?? agentRole);
781
+ const text = nonBlankString(description);
782
+ return {
783
+ ...(title ? { title } : {}),
784
+ ...(agentRole ? { role: agentRole } : {}),
785
+ ...(text ? { description: text } : {}),
786
+ };
787
+ }
425
788
  /**
426
789
  * The agent id that a successful SendMessage result resumed. The SDK puts it
427
790
  * in `tool_use_result.resumedAgentId` when a finished agent runs again.
@@ -438,22 +801,34 @@ export function resumedNativeSubagentId(toolUseResult) {
438
801
  * carried the resume. Otherwise the latest SendMessage call to `agentId` counts.
439
802
  */
440
803
  export function sendMessageResumePrompt(toolUses, agentId, resultToolUseIds = []) {
804
+ return sendMessageResume(toolUses, agentId, resultToolUseIds)?.text;
805
+ }
806
+ /**
807
+ * The SendMessage call that resumed the agent `agentId`, and its text when it
808
+ * has one; see {@link sendMessageResumePrompt}. The call is known even
809
+ * without text, so a late result still names the delegation it reports.
810
+ */
811
+ export function sendMessageResume(toolUses, agentId, resultToolUseIds = []) {
812
+ let withoutText;
441
813
  for (const toolUseId of resultToolUseIds) {
442
814
  const toolUse = toolUses[toolUseId];
443
815
  if (toolUse?.name !== "SendMessage")
444
816
  continue;
445
817
  const text = promptText(toolUse.input?.message);
446
818
  if (text)
447
- return text;
819
+ return { toolUseId, text };
820
+ withoutText ??= toolUseId;
448
821
  }
449
822
  if (resultToolUseIds.length > 0)
450
- return undefined;
451
- for (const toolUse of Object.values(toolUses).reverse()) {
823
+ return withoutText ? { toolUseId: withoutText } : undefined;
824
+ for (const [toolUseId, toolUse] of Object.entries(toolUses).reverse()) {
452
825
  if (toolUse?.name !== "SendMessage")
453
826
  continue;
454
827
  const input = toolUse.input;
455
- if (input?.to === agentId)
456
- return promptText(input.message);
828
+ if (input?.to !== agentId)
829
+ continue;
830
+ const text = promptText(input.message);
831
+ return text ? { toolUseId, text } : { toolUseId };
457
832
  }
458
833
  return undefined;
459
834
  }
@@ -596,6 +971,10 @@ function applySubagentIdentity(child, identity) {
596
971
  child.task = subagentDescription(identity.prompt, identity.description);
597
972
  }
598
973
  child.prompt ??= identity.prompt;
974
+ const labels = rfdLabels(identity.name, identity.subagentType ?? child.role, identity.description);
975
+ child.title = labels.title ?? child.title;
976
+ child.role = labels.role ?? child.role;
977
+ child.description = labels.description ?? child.description;
599
978
  }
600
979
  /** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
601
980
  function promptText(value) {
@@ -19,6 +19,7 @@ export declare class SettingsManager {
19
19
  private cwd;
20
20
  private effective;
21
21
  private managedDeniedModels;
22
+ private managedApiKeyHelper?;
22
23
  private watchers;
23
24
  private onChange?;
24
25
  private logger;
@@ -53,6 +54,11 @@ export declare class SettingsManager {
53
54
  * Returns the current merged settings
54
55
  */
55
56
  getSettings(): Settings;
57
+ /**
58
+ * The `apiKeyHelper` the CLI runs: managed settings outrank the `--settings`
59
+ * override, which outranks the user/project/local files.
60
+ */
61
+ getApiKeyHelper(settingsOverride?: Settings): string | undefined;
56
62
  /**
57
63
  * `deniedModels` entries from the managed tier, the only tier the CLI
58
64
  * honors them from.