@intentic/sandbox-contract 1.314.0 → 1.315.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/contracts/accounts.contract.d.ts +6 -0
  2. package/dist/contracts/accounts.contract.d.ts.map +1 -1
  3. package/dist/contracts/agent.contract.d.ts +1 -0
  4. package/dist/contracts/agent.contract.d.ts.map +1 -1
  5. package/dist/contracts/agent.contract.js +1 -1
  6. package/dist/contracts/agent.contract.js.map +1 -1
  7. package/dist/contracts/agents.contract.d.ts +293 -0
  8. package/dist/contracts/agents.contract.d.ts.map +1 -1
  9. package/dist/contracts/agents.contract.js +11 -1
  10. package/dist/contracts/agents.contract.js.map +1 -1
  11. package/dist/contracts/system.contract.d.ts +3 -0
  12. package/dist/contracts/system.contract.d.ts.map +1 -1
  13. package/dist/contracts/system.contract.js +1 -1
  14. package/dist/contracts/system.contract.js.map +1 -1
  15. package/dist/contracts/translator.contract.d.ts +12 -0
  16. package/dist/contracts/translator.contract.d.ts.map +1 -1
  17. package/dist/events/agent-events.d.ts.map +1 -1
  18. package/dist/events/agent-events.js.map +1 -1
  19. package/dist/events/agent-words.d.ts.map +1 -1
  20. package/dist/events/agent-words.js +5 -4
  21. package/dist/events/agent-words.js.map +1 -1
  22. package/dist/events/requests.js +1 -1
  23. package/dist/events/requests.js.map +1 -1
  24. package/dist/events/system-events.d.ts +2 -0
  25. package/dist/events/system-events.d.ts.map +1 -1
  26. package/dist/events/transcript.d.ts +1 -0
  27. package/dist/events/transcript.d.ts.map +1 -1
  28. package/dist/events/transcript.js +5 -1
  29. package/dist/events/transcript.js.map +1 -1
  30. package/dist/index.d.ts +315 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/models/plan-pools.d.ts +1 -0
  33. package/dist/models/plan-pools.d.ts.map +1 -1
  34. package/dist/models/plan-pools.js +3 -0
  35. package/dist/models/plan-pools.js.map +1 -1
  36. package/dist/schemas/agents.d.ts +7 -0
  37. package/dist/schemas/agents.d.ts.map +1 -1
  38. package/dist/schemas/agents.js +12 -1
  39. package/dist/schemas/agents.js.map +1 -1
  40. package/dist/schemas/automations.d.ts +1 -0
  41. package/dist/schemas/automations.d.ts.map +1 -1
  42. package/dist/schemas/personas.js +1 -1
  43. package/dist/schemas/personas.js.map +1 -1
  44. package/dist/schemas/providers/plan-limits.d.ts +19 -0
  45. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  46. package/dist/schemas/providers/plan-limits.js +9 -3
  47. package/dist/schemas/providers/plan-limits.js.map +1 -1
  48. package/dist/schemas/providers/provider-oauth.d.ts +6 -0
  49. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  50. package/dist/schemas/terminal.d.ts.map +1 -1
  51. package/dist/schemas/terminal.js +3 -3
  52. package/dist/schemas/terminal.js.map +1 -1
  53. package/dist/text/transcript-fold.d.ts +8 -0
  54. package/dist/text/transcript-fold.d.ts.map +1 -1
  55. package/dist/text/transcript-fold.js +67 -14
  56. package/dist/text/transcript-fold.js.map +1 -1
  57. package/package.json +5 -5
  58. package/src/contracts/agent.contract.ts +1 -1
  59. package/src/contracts/agents.contract.ts +12 -0
  60. package/src/contracts/system.contract.ts +1 -1
  61. package/src/events/agent-events.ts +4 -2
  62. package/src/events/agent-words.test.ts +8 -1
  63. package/src/events/agent-words.ts +8 -5
  64. package/src/events/requests.ts +1 -1
  65. package/src/events/transcript.ts +9 -3
  66. package/src/models/plan-pools.test.ts +7 -0
  67. package/src/models/plan-pools.ts +6 -1
  68. package/src/schemas/agents.ts +16 -1
  69. package/src/schemas/personas.ts +1 -1
  70. package/src/schemas/providers/plan-limits.ts +13 -3
  71. package/src/schemas/terminal.ts +12 -10
  72. package/src/text/transcript-fold.test.ts +107 -0
  73. package/src/text/transcript-fold.ts +95 -16
@@ -145,7 +145,7 @@ export interface ServiceFacts {
145
145
  // Why the sign-in needs renewing, where the provider said.
146
146
  readonly detail?: string | undefined;
147
147
  readonly seatRefusal?: string | undefined;
148
- readonly cooling?: { readonly until?: number | undefined; readonly reason?: string | undefined } | undefined;
148
+ readonly cooling?: { readonly until?: number | undefined; readonly reason?: string | undefined; readonly verify?: string | undefined } | undefined;
149
149
  readonly usage?: AccountUsage | undefined;
150
150
  }
151
151
 
@@ -240,6 +240,11 @@ export const serviceState = (facts: ServiceFacts, refusal?: ProviderRefusal, mod
240
240
  return { kind: "blocked", fix: "admin", reason: refusal.message };
241
241
  }
242
242
  const cooling = facts.cooling;
243
+ // A bench the provider wants a person for: the account's owner confirms it on the provider's page, and no wait or
244
+ // reconnect here lifts it. Outranks the retry instant, which is only when the proxy will ask again.
245
+ if (cooling?.verify !== undefined) {
246
+ return { kind: "blocked", fix: "verify", reason: cooling.reason ?? "the provider wants this account verified", url: cooling.verify };
247
+ }
243
248
  // A bench with no instant is one no wait lifts (a Google account with no project): somebody has to connect again.
244
249
  if (cooling !== undefined && cooling.until === undefined) {
245
250
  return { kind: "blocked", fix: "reconnect", reason: cooling.reason ?? "benched by the translator" };
@@ -418,6 +418,13 @@ export const AgentSummarySchema = z.object({
418
418
  .describe(
419
419
  "When somebody last opened it, in milliseconds. Newer activity than this is what makes it unread. Kept by the sandbox rather than by a browser, so clearing site data or picking up a phone does not resurrect every badge.",
420
420
  ),
421
+ // Browser-side words the daemon cannot read, reported so the unattended archive sweep never files them away.
422
+ unsentAt: z
423
+ .number()
424
+ .optional()
425
+ .describe(
426
+ "Since when somebody's composer has held a message for it that they have not sent yet, in milliseconds. While set, the sandbox never archives it on its own for being idle.",
427
+ ),
421
428
  attention: AgentAttentionSchema.describe("Which kinds of waiting-for-you it is doing."),
422
429
  // Only the causes that still hold, re-read live (conversations/land/standing.ts), never the stored report's own list: a
423
430
  // blocker the user has since cleared is not something to offer them an action about.
@@ -448,7 +455,7 @@ export const AgentSummarySchema = z.object({
448
455
  })
449
456
  .optional()
450
457
  .describe(
451
- "Subagents and child agents this one delegated to. Absent means it never has, which is most conversations. Their spend is their own and is not folded into this conversation's cost.",
458
+ "Subagents this one started, in-process and spawned alike. Absent means it never has, which is most conversations. Their spend is their own and is not folded into this conversation's cost.",
452
459
  ),
453
460
  // Cumulative output across every repo (base → branch tip), refreshed on each land; independent of what has actually
454
461
  // landed.
@@ -740,6 +747,14 @@ export const AgentAutoLandSchema = z.object({
740
747
  "Whether its work merges automatically when a turn finishes. Null clears the override and goes back to following the sandbox-wide setting, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
741
748
  ),
742
749
  });
750
+ // A composer's unsent words, reported by the editor that holds them: the words themselves stay in the browser.
751
+ export const AgentUnsentSchema = z.object({
752
+ id: z.string().min(1).describe("Which conversation."),
753
+ at: z
754
+ .number()
755
+ .nullable()
756
+ .describe("When the composer started holding the unsent message, in milliseconds. Null says it no longer holds one: it was sent or cleared."),
757
+ });
743
758
  // Same `null`-clears-the-override shape as autoLand, for this conversation's own answer to one ending's question.
744
759
  // One route rather than one per ending: they are the same decision asked about different walls, and three near-identical
745
760
  // verbs is how the surfaces drifted apart in the first place.
@@ -134,7 +134,7 @@ export const TURN_BRIEFING_NOTES: readonly TurnBriefingNote[] = [
134
134
  },
135
135
  {
136
136
  id: "delegation",
137
- label: "Spawning child agents",
137
+ label: "Spawning subagents",
138
138
  when: "First message, on a shell-only runtime, for a card that may delegate.",
139
139
  cost: "The agent does the work itself rather than handing parts of it to other conversations.",
140
140
  },
@@ -47,7 +47,7 @@ export const AccountUsageSchema = z.object({
47
47
  export type AccountUsage = z.infer<typeof AccountUsageSchema>;
48
48
  // Who has to act for a blocked account, which is the only thing a surface chooses its instruction by: the reason is the
49
49
  // words for a person, never a key.
50
- export const AccountFixSchema = z.enum(["reconnect", "admin", "wait"]);
50
+ export const AccountFixSchema = z.enum(["reconnect", "admin", "verify", "wait"]);
51
51
  export type AccountFix = z.infer<typeof AccountFixSchema>;
52
52
  // Whether an account can serve a turn, decided once by the daemon (models/plan-pools.ts `serviceState`) from every fact
53
53
  // it holds about it: a revoked sign-in, a lost seat, a translator bench, a refusal still standing, and its plan limits.
@@ -69,10 +69,11 @@ export const AccountStateSchema = z.discriminatedUnion("kind", [
69
69
  z.object({
70
70
  kind: z.literal("blocked"),
71
71
  fix: AccountFixSchema.describe(
72
- "Who can make it serve again: `reconnect` (sign in again on this sandbox), `admin` (an organisation admin hands the seat back), or `wait` (it lifts by itself, at `until` where known).",
72
+ "Who can make it serve again: `reconnect` (sign in again on this sandbox), `admin` (an organisation admin hands the seat back), `verify` (the account's owner confirms it on the provider's page, at `url`), or `wait` (it lifts by itself, at `until` where known).",
73
73
  ),
74
74
  reason: z.string().describe("Why, in words a person can act on: the provider's own sentence where it gave one."),
75
75
  until: z.number().optional().describe("When waiting lifts it, in epoch seconds, for `wait` only."),
76
+ url: z.string().optional().describe("The provider's page where the account's owner lifts it, for `verify` only."),
76
77
  }),
77
78
  z.object({ kind: z.literal("unknown").describe("Nothing blocks it and nothing has been measured: usable, never read as room.") }),
78
79
  ]);
@@ -151,6 +152,9 @@ export const TranslatorAccountSchema = z.object({
151
152
  // Why, in the words of what is missing: the proxy's own sentence where it gave one short enough to print,
152
153
  // else this sandbox's. Never a pasted upstream body.
153
154
  reason: z.string().optional(),
155
+ // The provider's page where the account's owner confirms it (Google's VALIDATION_REQUIRED). Present means
156
+ // a person has to act there: the proxy's retry fails until they do.
157
+ verify: z.string().optional(),
154
158
  })
155
159
  .optional(),
156
160
  state: AccountStateSchema.optional().describe(
@@ -205,7 +209,7 @@ export const AgentReplySchema = z.discriminatedUnion("kind", [
205
209
  feedback: z.string().optional().describe("Why not, which goes back to the model as the reason."),
206
210
  // Whole, not a patch: effort or an account named for one model means nothing on another.
207
211
  child: ChildRunSchema.optional().describe(
208
- "For a request to start a child agent: what to start it on instead of what the agent asked for. It replaces the whole run (model, account, effort and the rest), not only the fields it names. Ignored with a no, and on any other request.",
212
+ "For a request to start a subagent: what to start it on instead of what the agent asked for. It replaces the whole run (model, account, effort and the rest), not only the fields it names. Ignored with a no, and on any other request.",
209
213
  ),
210
214
  }),
211
215
  z.object({
@@ -400,6 +404,12 @@ export const SwitchAccountSchema = z.object({
400
404
  .describe(
401
405
  "Keep the provider session across the move (the model keeps everything, and re-reads all of it once on the other account) rather than opening a fresh one seeded from the record.",
402
406
  ),
407
+ run: z
408
+ .boolean()
409
+ .optional()
410
+ .describe(
411
+ "Also run a turn that a spent allowance, a stop or a refusal is holding, at once on the new account. Leave it out to only move the conversation: a held turn stays held until something asks for it.",
412
+ ),
403
413
  });
404
414
  export type SwitchAccount = z.infer<typeof SwitchAccountSchema>;
405
415
  export const AccountSwitchedSchema = z.object({
@@ -128,13 +128,15 @@ export const BrowsersListSchema = z.object({
128
128
  });
129
129
  export type BrowsersList = z.infer<typeof BrowsersListSchema>;
130
130
  export const BrowserNameParamSchema = z.object({ name: z.string().describe("Which browser.") });
131
- // Two kinds of started agent, listed together since from outside both are just another agent working that you did not
132
- // start:
133
- // subagent: the SDK's Agent/Task tool, tracked via SubagentStart/Stop hooks and task_* messages joined on toolUseId.
134
- // spawned: a full child agent on any provider, started through the daemon's spawn door (children/children.ts); the
135
- // daemon reports its life directly.
136
- // id is the spawning tool call's id for a subagent, the child's own conversation id for a spawned one. A kind changes
137
- // only how you watch it live.
131
+ // A subagent is any agent another agent started, and every surface shows one the same way whichever mechanism started
132
+ // it. The kind names the mechanism, never a second sort of thing:
133
+ // subagent: in-process, the runtime's own Agent/Task tool, tracked via SubagentStart/Stop hooks and task_* messages
134
+ // joined on toolUseId; it works in its parent's turn and tree, on its parent's provider.
135
+ // spawned: a full agent on any provider, started through the daemon's spawn door (agent/subagents/children.ts) as a
136
+ // conversation of its own, in its own worktree; the daemon reports its life directly, and it can outlive its parent's
137
+ // turn, be steered, and land its own work.
138
+ // id is the spawning tool call's id for an in-process one, its own conversation id for a spawned one. The kind changes
139
+ // only where its record is read from and what else can be done with it, never how it looks.
138
140
  export const SubagentKindSchema = z.enum(["subagent", "spawned"]);
139
141
  export type SubagentKind = z.infer<typeof SubagentKindSchema>;
140
142
  // running/pending/blocked are live, the rest terminal. Uses the SDK's own task vocabulary rather than AgentStatus.
@@ -168,7 +170,7 @@ export const SubagentSessionSchema = z.object({
168
170
  "The id of the tool call that started it (an SDK child) or the child's own conversation id (a spawned one); either way both sides already hold it, so a card links to its subagent with the id it has and the subagent points back the same way.",
169
171
  ),
170
172
  kind: SubagentKindSchema.describe(
171
- "What sort of subagent: one the runtime's own Task tool spawned in-process, or a full child agent the daemon started for the turn. It changes only how you watch it.",
173
+ "How it was started: in-process by the runtime's own Agent/Task tool, or spawned by the daemon as a conversation of its own, on any provider. It changes where its record is read from and what else can be done with it, never what it is.",
172
174
  ),
173
175
  // The conversation whose turn started it; how a card links back to the chat it belongs to.
174
176
  conversationId: z.string().describe("The conversation whose turn started it, and the way back to the chat it belongs to."),
@@ -177,7 +179,7 @@ export const SubagentSessionSchema = z.object({
177
179
  description: z.string().optional().describe("What it was asked to do, in one line."),
178
180
  model: z.string().optional().describe("Which model it runs on."),
179
181
  // Which provider serves a spawned child; an SDK subagent implies its own (its parent's).
180
- provider: z.string().optional().describe("Which provider serves it, for a child agent spawned across providers."),
182
+ provider: z.string().optional().describe("Which provider serves it, for a subagent spawned across providers."),
181
183
  // How deep in the spawn tree; 1 means the turn itself started it. A subagent can itself delegate further.
182
184
  spawnDepth: z
183
185
  .number()
@@ -216,7 +218,7 @@ export const SubagentSessionSchema = z.object({
216
218
  });
217
219
  export type SubagentSession = z.infer<typeof SubagentSessionSchema>;
218
220
  export const SubagentsListSchema = z.object({
219
- sessions: z.array(SubagentSessionSchema).describe("Every subagent and child agent this sandbox's conversations have started."),
221
+ sessions: z.array(SubagentSessionSchema).describe("Every subagent this sandbox's conversations have started, in-process and spawned alike."),
220
222
  });
221
223
  export type SubagentsList = z.infer<typeof SubagentsListSchema>;
222
224
  export const SubagentIdParamSchema = z.object({ id: z.string() });
@@ -108,6 +108,113 @@ describe("foldTurn", () => {
108
108
  ]);
109
109
  });
110
110
 
111
+ // A spawned subagent is named by its own conversation, not the call that started it; the spawn answers with that
112
+ // name, which is how its card is found, so it reads on its card exactly as an in-process one does.
113
+ it("puts a spawned subagent on the card of the call whose result names it", () => {
114
+ const events: AgentEvent[] = [
115
+ { kind: "tool_call", id: "call-9", name: "mcp__subagents__spawn", category: "other", status: "in_progress" },
116
+ { kind: "subagent", id: "sub-brave-otter", subagentKind: "spawned", agentType: "Codex", description: "Port the parser", provider: "codex", background: true },
117
+ { kind: "subagent_update", id: "sub-brave-otter", status: "pending", summary: "Waiting for memory." },
118
+ { kind: "tool_call_update", id: "call-9", status: "completed", content: [{ type: "text", text: '{"ok":true,"child":"sub-brave-otter"}' }] },
119
+ { kind: "subagent_update", id: "sub-brave-otter", status: "running", toolUses: 3, lastTool: "Edit" },
120
+ ];
121
+ expect(foldOf("fan out", events).at(-1)?.tools).toEqual([
122
+ {
123
+ id: "call-9",
124
+ name: "mcp__subagents__spawn",
125
+ category: "other",
126
+ status: "completed",
127
+ content: [{ type: "text", text: '{"ok":true,"child":"sub-brave-otter"}' }],
128
+ subagent: {
129
+ id: "sub-brave-otter",
130
+ kind: "spawned",
131
+ agentType: "Codex",
132
+ description: "Port the parser",
133
+ provider: "codex",
134
+ background: true,
135
+ status: "running",
136
+ summary: "Waiting for memory.",
137
+ toolUses: 3,
138
+ lastTool: "Edit",
139
+ },
140
+ },
141
+ ]);
142
+ });
143
+
144
+ // The shell door prints the new id on its first line, so a runtime with no spawn tool of its own gets the same card.
145
+ it("puts a subagent spawned from the shell on its command's card", () => {
146
+ const events: AgentEvent[] = [
147
+ { kind: "subagent", id: "sub-quiet-fox", subagentKind: "spawned", agentType: "Grok", description: "Draft the docs" },
148
+ { kind: "tool_call", id: "b1", name: "Bash", category: "execute", status: "in_progress", target: "agents spawn --provider grok --model grok-4 'Draft the docs'" },
149
+ { kind: "tool_call_update", id: "b1", status: "completed", content: [{ type: "text", text: "sub-quiet-fox\nStarted." }] },
150
+ { kind: "subagent_update", id: "sub-quiet-fox", status: "completed", summary: "Drafted." },
151
+ ];
152
+ expect(foldOf("fan out", events).at(-1)?.tools?.[0]?.subagent).toEqual({
153
+ id: "sub-quiet-fox",
154
+ kind: "spawned",
155
+ agentType: "Grok",
156
+ description: "Draft the docs",
157
+ status: "completed",
158
+ summary: "Drafted.",
159
+ });
160
+ });
161
+
162
+ // Parallel spawns answer in any order; each card takes the one its own result names, and a card holds one subagent.
163
+ it("keeps each of several spawned subagents on its own card", () => {
164
+ const events: AgentEvent[] = [
165
+ { kind: "tool_call", id: "s1", name: "mcp__subagents__spawn", category: "other", status: "in_progress" },
166
+ { kind: "tool_call", id: "s2", name: "mcp__subagents__spawn", category: "other", status: "in_progress" },
167
+ { kind: "subagent", id: "sub-a", subagentKind: "spawned", description: "A" },
168
+ { kind: "subagent", id: "sub-b", subagentKind: "spawned", description: "B" },
169
+ { kind: "tool_call_update", id: "s2", status: "completed", content: [{ type: "text", text: '{"ok":true,"child":"sub-b"}' }] },
170
+ { kind: "tool_call_update", id: "s1", status: "completed", content: [{ type: "text", text: '{"ok":true,"child":"sub-a"}' }] },
171
+ { kind: "tool_call", id: "w1", name: "mcp__subagents__wait", category: "other", status: "in_progress" },
172
+ { kind: "tool_call_update", id: "w1", status: "completed", content: [{ type: "text", text: '{"agent":{"id":"sub-a"},"also":"sub-b"}' }] },
173
+ ];
174
+ const tools = foldOf("fan out", events).at(-1)?.tools ?? [];
175
+ expect(tools.map((tool) => [tool.id, tool.subagent?.id, tool.subagent?.description])).toEqual([
176
+ ["s1", "sub-a", "A"],
177
+ ["s2", "sub-b", "B"],
178
+ ["w1", undefined, undefined],
179
+ ]);
180
+ });
181
+
182
+ // An in-process subagent heard a beat before its call still lands on that call, under the call's own id.
183
+ it("holds a subagent heard before its card until the card appears", () => {
184
+ const events: AgentEvent[] = [
185
+ { kind: "subagent", id: "task-2", subagentKind: "subagent", agentType: "Explore" },
186
+ { kind: "tool_call", id: "task-2", name: "Agent", category: "other", status: "in_progress" },
187
+ ];
188
+ expect(foldOf("delegate", events).at(-1)?.tools?.[0]?.subagent).toEqual({ kind: "subagent", agentType: "Explore", status: "running" });
189
+ });
190
+
191
+ // Live, the card's claim goes out as the patch of the result that made it, so a watching window draws the same card.
192
+ it("sends a spawned subagent's placement as the claiming card's own patch", () => {
193
+ const fold = new TranscriptFold(openingOf("fan out"));
194
+ fold.apply({ kind: "tool_call", id: "call-9", name: "mcp__subagents__spawn", category: "other", status: "in_progress" });
195
+ expect(fold.apply({ kind: "subagent", id: "sub-x", subagentKind: "spawned", description: "Port" })).toEqual([]);
196
+ const [patch] = fold.apply({ kind: "tool_call_update", id: "call-9", status: "completed", content: [{ type: "text", text: "sub-x" }] });
197
+ expect(patch).toEqual({
198
+ op: "tool",
199
+ index: 1,
200
+ tool: {
201
+ id: "call-9",
202
+ name: "mcp__subagents__spawn",
203
+ category: "other",
204
+ status: "completed",
205
+ content: [{ type: "text", text: "sub-x" }],
206
+ subagent: { id: "sub-x", kind: "spawned", description: "Port", status: "running" },
207
+ },
208
+ });
209
+ expect(fold.apply({ kind: "subagent_update", id: "sub-x", status: "blocked", summary: "Which port?" })).toEqual([
210
+ {
211
+ op: "tool",
212
+ index: 1,
213
+ tool: expect.objectContaining({ subagent: { id: "sub-x", kind: "spawned", description: "Port", status: "blocked", summary: "Which port?" } }),
214
+ },
215
+ ]);
216
+ });
217
+
111
218
  it("drops a subagent's frames when the card that spawned them is absent", () => {
112
219
  const events: AgentEvent[] = [
113
220
  { kind: "delta", text: "delegating" },
@@ -20,6 +20,11 @@ import { unspokenPromptRow } from "../events/agent-words.js";
20
20
  // Folds a turn's frames into rows once, live and for the settled record alike, so a reopened chat matches what was on
21
21
  // screen. `tag` selects the stream read: undefined is the main turn, a tool-call id is the subagent it spawned; other
22
22
  // frames nest under the card that spawned them. Rows mutate in place; every patch carries a copy of what it names.
23
+ //
24
+ // Every subagent lands on the card of the call that started it, whichever mechanism started it. An in-process one (the
25
+ // runtime's own Agent/Task tool) is named by that call's id, so its card is found at once. A spawned one is named by its
26
+ // own conversation id, and every door that spawns one answers with that id (the spawn tool's `child`, the `agents
27
+ // spawn` CLI's first line), so its frames wait until a card's result names it, then ride that card from there on.
23
28
 
24
29
  export type TurnEnding = "settled" | "stopped";
25
30
 
@@ -40,6 +45,13 @@ const defined = <T extends object>(value: T): Partial<T> =>
40
45
  // A card's own fields: what a `tool` patch carries, so a delegation's growing subtree never rides one.
41
46
  const ownFields = ({ children: _children, thinking: _thinking, ...own }: TranscriptTool): TranscriptTool => own;
42
47
 
48
+ // Everything a card's result said, as one string: what a subagent waiting for its card is looked for in.
49
+ const resultWords = (tool: TranscriptTool): string =>
50
+ (tool.content ?? [])
51
+ .filter((entry) => entry.type === "text")
52
+ .map((entry) => entry.text)
53
+ .join("\n");
54
+
43
55
  const cardOf = (event: Extract<AgentEvent, { kind: "tool_call" }>): TranscriptTool => ({
44
56
  id: event.id,
45
57
  name: event.name,
@@ -212,6 +224,11 @@ export class TranscriptFold {
212
224
  // Index of the open assistant bubble; always the last row, since every other row kind closes it first.
213
225
  private bubble: number | undefined;
214
226
  private readonly cards = new Map<string, CardPlace>();
227
+ // A subagent's own id to the card of the call that started it, once that card is known.
228
+ private readonly placed = new Map<string, string>();
229
+ // A subagent heard before its card: born, and perhaps already moving, while the call that started it had not yet
230
+ // said its id. Placed as soon as a card claims it; dropped with the fold if none ever does.
231
+ private readonly unplaced = new Map<string, TranscriptSubagent>();
215
232
  // requestId to the row holding its card, for frames landing on it later (a reply, a late sentence, a receipt).
216
233
  private readonly parked = new Map<string, number>();
217
234
  // The turn's opening user row, where the checkpoint and daemon notes land; cleared once `retract` takes it back.
@@ -266,6 +283,7 @@ export class TranscriptFold {
266
283
  const row = this.rows[index]!;
267
284
  row.tools = [...(row.tools ?? []), tool];
268
285
  this.cards.set(tool.id, { tool, row: index });
286
+ this.claimById(tool);
269
287
  return [...opened, { op: "tool", index, tool: structuredClone(tool) }];
270
288
  }
271
289
  case "tool_call_update":
@@ -276,27 +294,16 @@ export class TranscriptFold {
276
294
  }
277
295
  if (event.content !== undefined) {
278
296
  tool.content = event.content;
297
+ this.claimByResult(tool);
279
298
  }
280
299
  if (event.locations !== undefined) {
281
300
  tool.locations = event.locations;
282
301
  }
283
302
  });
284
- case "subagent": {
285
- // The frame's id is the spawning call's id, so the subagent record lands on that card.
286
- const { kind: _kind, id, subagentKind, ...rest } = event;
287
- return this.patchCard(id, (tool) => {
288
- tool.subagent = { ...rest, kind: subagentKind, status: "running" };
289
- });
290
- }
291
- case "subagent_update": {
292
- // Present fields replace, absent ones leave the child alone, as in tool_call_update.
293
- const { kind: _kind, id, ...patch } = event;
294
- return this.patchCard(id, (tool) => {
295
- if (tool.subagent !== undefined) {
296
- tool.subagent = { ...tool.subagent, ...(defined(patch) as Partial<TranscriptSubagent>) };
297
- }
298
- });
299
- }
303
+ case "subagent":
304
+ return this.subagentBorn(event);
305
+ case "subagent_update":
306
+ return this.subagentMoved(event);
300
307
  case "todos": {
301
308
  const [index, opened] = this.open();
302
309
  this.rows[index]!.todos = [...event.items];
@@ -561,11 +568,83 @@ export class TranscriptFold {
561
568
  const child = cardOf(event);
562
569
  place.tool.children = [...(place.tool.children ?? []), child];
563
570
  this.cards.set(child.id, { tool: child, row: place.row, parent });
571
+ this.claimById(child);
564
572
  return [{ op: "tool", index: place.row, tool: structuredClone(child), parent }];
565
573
  }
566
574
  return [];
567
575
  }
568
576
 
577
+ // A subagent starts: on its card when that is known, else held until a card claims it. A later birth under the same
578
+ // id (a follow-up turn of the same subagent) starts its card's state over.
579
+ private subagentBorn(event: Extract<AgentEvent, { kind: "subagent" }>): TranscriptPatch[] {
580
+ const { kind: _kind, id, subagentKind, ...rest } = event;
581
+ const born: TranscriptSubagent = { ...rest, kind: subagentKind, status: "running" };
582
+ const card = this.cardOfSubagent(id);
583
+ if (card === undefined) {
584
+ this.unplaced.set(id, born);
585
+ return [];
586
+ }
587
+ return this.patchCard(card, (tool) => this.place(tool, id, born));
588
+ }
589
+
590
+ // A subagent moves: present fields replace, absent ones leave it alone, as in tool_call_update; one still waiting for
591
+ // its card moves where it waits.
592
+ private subagentMoved(event: Extract<AgentEvent, { kind: "subagent_update" }>): TranscriptPatch[] {
593
+ const { kind: _kind, id, ...patch } = event;
594
+ const moved = defined(patch) as Partial<TranscriptSubagent>;
595
+ const card = this.cardOfSubagent(id);
596
+ if (card !== undefined) {
597
+ return this.patchCard(card, (tool) => {
598
+ if (tool.subagent !== undefined) {
599
+ tool.subagent = { ...tool.subagent, ...moved };
600
+ }
601
+ });
602
+ }
603
+ const waiting = this.unplaced.get(id);
604
+ if (waiting !== undefined) {
605
+ this.unplaced.set(id, { ...waiting, ...moved });
606
+ }
607
+ return [];
608
+ }
609
+
610
+ // The card a subagent's frames land on: the one already carrying it, else the one sharing its id (an in-process
611
+ // subagent is named by the call that started it); undefined while the call that started it has not yet named it.
612
+ private cardOfSubagent(id: string): string | undefined {
613
+ return this.placed.get(id) ?? (this.cards.has(id) ? id : undefined);
614
+ }
615
+
616
+ // Puts a subagent on the card that started it. Its own id rides along only where it differs from the card's, so the
617
+ // card can still name it to the roster, `wait` and its own page.
618
+ private place(tool: TranscriptTool, id: string, subagent: TranscriptSubagent): void {
619
+ const { id: _id, ...state } = subagent;
620
+ tool.subagent = id === tool.id ? state : { ...state, id };
621
+ this.placed.set(id, tool.id);
622
+ this.unplaced.delete(id);
623
+ }
624
+
625
+ // A card appearing under the id a subagent was already heard by: the stream said the child before the call.
626
+ private claimById(tool: TranscriptTool): void {
627
+ const waiting = this.unplaced.get(tool.id);
628
+ if (waiting !== undefined) {
629
+ this.place(tool, tool.id, waiting);
630
+ }
631
+ }
632
+
633
+ // A call whose result names a subagent still waiting for its card is the call that started it. A card carries one
634
+ // subagent, so one that already has its own claims nothing more.
635
+ private claimByResult(tool: TranscriptTool): void {
636
+ if (this.unplaced.size === 0 || tool.subagent !== undefined) {
637
+ return;
638
+ }
639
+ const words = resultWords(tool);
640
+ for (const [id, waiting] of this.unplaced) {
641
+ if (words.includes(id)) {
642
+ this.place(tool, id, waiting);
643
+ return;
644
+ }
645
+ }
646
+ }
647
+
569
648
  // Returns the bubble frames write to, opening a fresh assistant row when the last one was retired.
570
649
  private open(): [number, TranscriptPatch[]] {
571
650
  if (this.bubble !== undefined) {