@ian-pascoe/pi-minimal-subagents 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,9 @@ From this package checkout, load the source directly with
42
42
  response arrives automatically as a steer message. Without `turn_id`, a wait
43
43
  skips turns whose result you already claimed or received automatically, and
44
44
  never repeats an automatically delivered result: it reports
45
- `already_delivered: true` instead of the output.
45
+ `already_delivered: true` instead of the output. If the result is handed to Pi but
46
+ not yet in your conversation, it also sets `delivery_pending: true` and the
47
+ result arrives as a separate message; no reread needed.
46
48
  3. `agent_message` continues an idle child: the result reports
47
49
  `disposition: "started-turn"` with the new `turn_id`. A plain
48
50
  `subagent_wait({ agent_id })` targets that new turn, not the first result;
@@ -288,11 +290,22 @@ assistant message but omits image data. Timeout Wait Events carry only a compact
288
290
  progress snapshot. Children target only direct children; the Root Agent
289
291
  may inspect any descendant. Model-facing status reports a `child_count` but no
290
292
  nested `children` summaries. Reported `usage` always includes `cacheWrite1h`
291
- and `reasoning`, as `0` when the provider reports none. Tool-result text and
292
- `structuredContent` round every `usage.cost` field to six decimal places of USD;
293
+ and `reasoning`, as `0` when the provider reports none. `structuredContent`
294
+ rounds every `usage.cost` field to six decimal places of USD;
293
295
  exact values stay in session data. `subagent_cancel`
294
296
  lists in `affected_agent_ids` only agents whose active turns it cancelled.
295
297
 
298
+ Coordinator Tools give the model compact text, not JSON: a spawn is one line
299
+ naming the tool preset and count rather than the tool list; a settled wait is
300
+ status, elapsed time, one token and cost total, then the output; cancel and
301
+ delete are one line each, and a cancel that found nothing running says so.
302
+ Targeted `subagent_status` opens with a summary line, lists active tools once,
303
+ describes the Launch Contract grant and capability ceiling relative to them,
304
+ and previews the task, latest output, and last three Recent Activity items;
305
+ `verbose: true` adds the full task, output, ceiling, usage breakdown, recent
306
+ messages, and Recent Activity. Codemode scripts receive the complete record in
307
+ `structuredContent`, and the transcript renderer reads `details`.
308
+
296
309
  `session_context` defaults to `omit`: the child starts with only its system
297
310
  prompt and task, so the task should be a self-contained brief covering the goal,
298
311
  target files and non-goals, the change or question, and acceptance criteria.
@@ -352,7 +365,9 @@ remaining observable turn, falling back to the active, then latest, turn. When
352
365
  that fallback reaches a result already delivered to you automatically, whether
353
366
  still queued or settled, the wait returns `event: "turn"` with
354
367
  `already_delivered: true`, the turn identity, and status, but no output; it does
355
- not claim the turn, and `turn_id` rereads the full result. A
368
+ not claim the turn, and `turn_id` rereads the full result. A result handed but
369
+ not yet recorded in your branch also carries `delivery_pending: true`: it will
370
+ arrive as a separate message (redelivered if Pi discards the queued steer), so no reread is needed. A
356
371
  caller may have only one outstanding wait for the same source turn; a
357
372
  concurrent duplicate is rejected instead of competing for one Wait Event.
358
373
  When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
@@ -418,6 +433,13 @@ process-local fork handoff is lost, recovery reads only the destination's
418
433
  selected branch and proceeds only when its canonical `parentSession` proves the
419
434
  source file; it never substitutes the source session's newer head.
420
435
 
436
+ Every child session also carries an Agent Lineage Record, defined by
437
+ `@ian-pascoe/pi-utils/agent-lineage`, naming its own session, its Root Agent's
438
+ session, its agent ID, and its parent agent. Other extensions read it to find a
439
+ Child Agent's ancestors and descendants without depending on this package; it
440
+ grants them nothing. A fork clone writes its own record when its destination
441
+ adopts it. Children created before the record existed have none.
442
+
421
443
  ## Tool annotations
422
444
 
423
445
  The coordinator tools declare MCP-style `annotations`, which Pi reports through `pi.getAllTools()` for permission extensions and does not send to model providers. `subagent` is destructive and open-world because a Child Agent can use any tool it is granted. `agent_message` is not read-only but is non-destructive and closed-world. `subagent_cancel` is non-destructive and idempotent; `subagent_delete` is destructive and idempotent. `subagent_status` and `subagent_wait` are read-only (`subagent_wait` consumes queued deliveries, so it is not idempotent).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -31,7 +31,7 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "@ian-pascoe/pi-utils": "^0.6.0",
34
+ "@ian-pascoe/pi-utils": "^0.7.0",
35
35
  "minimatch": "^10.2.6"
36
36
  },
37
37
  "peerDependencies": {
@@ -9,7 +9,7 @@ disable-model-invocation: true
9
9
 
10
10
  1. Read the relevant access, spawn, capability, delivery, or recovery section of [`../../README.md`](../../README.md).
11
11
  2. For Subagent Access or missing Root Agent Coordinator Tools, run `/subagents status` first and record the effective source plus actual active-tool count.
12
- 3. From the direct parent, use `subagent_status` to capture the Child Agent's Launch Contract, effective tools, Runtime Profile, dependencies, result, and Recent Activity.
12
+ 3. From the direct parent, use `subagent_status` with `verbose: true` to capture the Child Agent's Launch Contract, effective tools, Runtime Profile, dependencies, result, and Recent Activity.
13
13
  4. Classify the fault as Subagent Access, launch resolution, adjacency, ordinary-tool ceiling, delivery state, or Registry recovery.
14
14
  5. For access changes, use the narrowest `/subagents enable|disable|reset` scope. For other configuration, identify the effective layer, make one scoped edit, validate JSON, and reload Pi.
15
15
  6. Repeat the same spawn or coordination operation. Finish when it succeeds or one named access, launch, adjacency, capability, delivery, or recovery boundary is evidenced.
@@ -70,6 +70,7 @@ import type {
70
70
  TurnResult,
71
71
  WaitMessageResult,
72
72
  WaitTimeoutResult,
73
+ AutomaticDelivery,
73
74
  WaitDeliveredTurnResult,
74
75
  WaitResult,
75
76
  } from "./minimal-subagents-types.js";
@@ -155,6 +156,7 @@ function terminalWaitResult(result: TurnResult, messages: WaitMessageResult[] =
155
156
  function alreadyDeliveredWaitResult(
156
157
  result: TurnResult,
157
158
  messages: WaitMessageResult[],
159
+ delivery: AutomaticDelivery,
158
160
  ): WaitDeliveredTurnResult {
159
161
  const delivered: WaitDeliveredTurnResult = {
160
162
  event: "turn",
@@ -163,6 +165,7 @@ function alreadyDeliveredWaitResult(
163
165
  status: result.status,
164
166
  already_delivered: true,
165
167
  };
168
+ if (delivery === "handed") delivered.delivery_pending = true;
166
169
  if (messages.length > 0) delivered.messages = structuredClone(messages);
167
170
  return delivered;
168
171
  }
@@ -515,8 +518,12 @@ export class MinimalSubagentsCoordinator {
515
518
  const messages = this.drainPendingParentMessages(callerId, agentId, turnId);
516
519
  // A default wait falls back to the latest turn, whose result may already have been delivered
517
520
  // automatically; repeating its output would deliver it twice. An explicit turn_id rereads it.
518
- if (requestedTurnId === undefined && this.wasAlreadyDelivered(callerId, retainedResult)) {
519
- return Promise.resolve(alreadyDeliveredWaitResult(retainedResult, messages));
521
+ const delivery =
522
+ requestedTurnId === undefined
523
+ ? this.automaticDelivery(callerId, retainedResult)
524
+ : undefined;
525
+ if (delivery) {
526
+ return Promise.resolve(alreadyDeliveredWaitResult(retainedResult, messages, delivery));
520
527
  }
521
528
  this.claimTerminalDelivery(callerId, retainedResult);
522
529
  return Promise.resolve(terminalWaitResult(retainedResult, messages));
@@ -1772,17 +1779,23 @@ export class MinimalSubagentsCoordinator {
1772
1779
  /**
1773
1780
  * Whether this result was already delivered to the caller automatically: it is handed, or the
1774
1781
  * caller's branch holds its automatic result message. A wait's own tool result does not count.
1782
+ * "recorded" means the branch already holds it; "handed" means it is queued to Pi but not yet
1783
+ * recorded, so it still arrives as a separate message (redelivered if Pi discarded the queued steer).
1775
1784
  */
1776
- private wasAlreadyDelivered(callerId: string, result: TurnResult): boolean {
1777
- return (
1778
- this.handedTerminalKeys.has(deliveryTurnKey(result.agent_id, result.turn_id)) ||
1785
+ private automaticDelivery(callerId: string, result: TurnResult): AutomaticDelivery | undefined {
1786
+ if (
1779
1787
  this.hasRecipientDeliveryEvidence(
1780
1788
  callerId,
1781
1789
  result.agent_id,
1782
1790
  result.turn_id,
1783
1791
  automaticResultMessageId(result.agent_id, result.turn_id),
1784
1792
  )
1785
- );
1793
+ ) {
1794
+ return "recorded";
1795
+ }
1796
+ return this.handedTerminalKeys.has(deliveryTurnKey(result.agent_id, result.turn_id))
1797
+ ? "handed"
1798
+ : undefined;
1786
1799
  }
1787
1800
 
1788
1801
  private hasDeliveryEvidence(delivery: PersistedDelivery): boolean {
@@ -147,6 +147,7 @@ const WaitTurnDetailsSchema = Type.Object({
147
147
  elapsed_ms: Type.Optional(Type.Number()),
148
148
  usage: Type.Optional(RenderUsageSchema),
149
149
  already_delivered: Type.Optional(Type.Boolean()),
150
+ delivery_pending: Type.Optional(Type.Boolean()),
150
151
  messages: Type.Optional(Type.Array(WaitMessageDetailsSchema)),
151
152
  });
152
153
  const WaitTimeoutDetailsSchema = Type.Object({
@@ -314,6 +315,7 @@ export const CoordinatorToolOutputSchemas = {
314
315
  ...Type.Pick(CoordinatorTurnOutputSchema, ["agent_id", "turn_id", "status"]).properties,
315
316
  event: Type.Literal("turn"),
316
317
  already_delivered: Type.Literal(true),
318
+ delivery_pending: Type.Optional(Type.Literal(true)),
317
319
  messages: Type.Optional(Type.Array(CoordinatorWaitMessageOutputSchema)),
318
320
  ...CoordinatorWaitSourceOutputSchema,
319
321
  }),
@@ -379,7 +379,7 @@ function latestProgressActivity(activity: readonly RenderRecentActivity[]): Rend
379
379
  }
380
380
 
381
381
  /** Format a turn's cost for summary rows; sub-cent costs keep enough precision to be non-zero. */
382
- function formatSubagentCost(usage: Usage | undefined): string | undefined {
382
+ export function formatSubagentCost(usage: Usage | undefined): string | undefined {
383
383
  const total = usage?.cost.total;
384
384
  if (total === undefined || !(total > 0)) return undefined;
385
385
  return `$${total.toFixed(total < 0.01 ? 4 : 2)}`;
@@ -591,6 +591,11 @@ function renderWaitProgress(
591
591
 
592
592
  /** Shown instead of the output of a result already delivered to the parent automatically. */
593
593
  const ALREADY_DELIVERED_TEXT = "Already delivered automatically; wait with turn_id to reread it.";
594
+ const DELIVERY_PENDING_TEXT = "Handed to you automatically; it arrives as a separate message.";
595
+
596
+ function alreadyDeliveredText(details: { delivery_pending?: boolean }): string {
597
+ return details.delivery_pending ? DELIVERY_PENDING_TEXT : ALREADY_DELIVERED_TEXT;
598
+ }
594
599
 
595
600
  /** The activity labels of a timeout result, including pre-compact results that carried the full status. */
596
601
  function timeoutActivityLabels(
@@ -616,7 +621,7 @@ function collapsedWaitBody(
616
621
  : undefined;
617
622
  }
618
623
  if (details.already_delivered) {
619
- return collapsedText(theme, theme.fg("muted", ALREADY_DELIVERED_TEXT));
624
+ return collapsedText(theme, theme.fg("muted", alreadyDeliveredText(details)));
620
625
  }
621
626
  const output = details.output ?? "";
622
627
  if (status === "completed") {
@@ -691,7 +696,7 @@ function renderWaitResult(
691
696
  }
692
697
  const output = details.output ?? "";
693
698
  if (details.already_delivered) {
694
- appendTextSection(container, theme, "Output", ALREADY_DELIVERED_TEXT);
699
+ appendTextSection(container, theme, "Output", alreadyDeliveredText(details));
695
700
  } else if (status === "completed") {
696
701
  if (output.length > 0) {
697
702
  appendComponentSection(
@@ -0,0 +1,408 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import {
3
+ formatSubagentCost,
4
+ formatSubagentDuration,
5
+ formatSubagentPreview,
6
+ formatSubagentTokenCount,
7
+ formatSubagentUsage,
8
+ } from "./minimal-subagents-rendering.js";
9
+ import type {
10
+ AgentDetail,
11
+ AgentMessageResult,
12
+ AgentSummary,
13
+ CancelResult,
14
+ DeleteResult,
15
+ SpawnResult,
16
+ StatusResult,
17
+ ToolSelection,
18
+ WaitDeliveredTurnResult,
19
+ WaitMessageResult,
20
+ WaitResult,
21
+ WaitTimeoutResult,
22
+ WaitTurnResult,
23
+ } from "./minimal-subagents-types.js";
24
+
25
+ /*
26
+ * Model-facing text for Coordinator Tool results. Each renderer states the facts a model acts on
27
+ * in a few lines; `details` and `structuredContent` keep the complete record, so nothing dropped
28
+ * here is lost to codemode scripts or the transcript renderer.
29
+ */
30
+
31
+ /** Width of the one-line task preview in summaries. */
32
+ const TASK_PREVIEW_WIDTH = 120;
33
+ /** Width of the one-line latest-result preview in a non-verbose status. */
34
+ const RESULT_PREVIEW_WIDTH = 200;
35
+ /** Width of each one-line Recent Activity preview in a non-verbose status. */
36
+ const ACTIVITY_PREVIEW_WIDTH = 120;
37
+ /** Recent Activity items a non-verbose status previews, newest last. */
38
+ const STATUS_ACTIVITY_PREVIEW_COUNT = 3;
39
+
40
+ function joinParts(parts: readonly (string | undefined)[]): string {
41
+ return parts.filter((part): part is string => Boolean(part)).join(" · ");
42
+ }
43
+
44
+ function profile(model: string, thinkingLevel: string): string {
45
+ return `${model} (${thinkingLevel})`;
46
+ }
47
+
48
+ /** One `tokens · cost` total, or undefined when usage is unknown. */
49
+ function usageTotal(usage: Usage | undefined): string | undefined {
50
+ if (!usage) return undefined;
51
+ return joinParts([
52
+ `${formatSubagentTokenCount(usage.totalTokens) ?? "0"} tokens`,
53
+ formatSubagentCost(usage),
54
+ ]);
55
+ }
56
+
57
+ function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
58
+ return `${count} ${count === 1 ? singular : pluralForm}`;
59
+ }
60
+
61
+ function listOrNone(values: readonly string[]): string {
62
+ return values.length === 0 ? "none" : values.join(", ");
63
+ }
64
+
65
+ function without(values: readonly string[], excluded: readonly string[]): string[] {
66
+ const excludedSet = new Set(excluded);
67
+ return values.filter((value) => !excludedSet.has(value));
68
+ }
69
+
70
+ /** Describe the granted tools relative to the requested selection, without repeating a preset's list. */
71
+ function spawnToolsSummary(
72
+ granted: readonly string[],
73
+ requested: ToolSelection | undefined,
74
+ ): string {
75
+ const count = plural(granted.length, "tool");
76
+ if (requested === undefined) return `tools: inherited, ${count}`;
77
+ if (!Array.isArray(requested)) return `tools: ${requested} preset, ${count}`;
78
+ const added = without(granted, requested);
79
+ const missing = without(requested, granted);
80
+ const diff = [
81
+ added.length > 0 ? `+${added.join(", +")}` : undefined,
82
+ missing.length > 0 ? `-${missing.join(", -")}` : undefined,
83
+ ].filter(Boolean);
84
+ return `tools: your list, ${count}${diff.length > 0 ? ` (${diff.join("; ")})` : ""}`;
85
+ }
86
+
87
+ /** One launch line plus any tool-resolution warnings. */
88
+ export function formatSpawnResultText(
89
+ result: SpawnResult,
90
+ requestedTools: ToolSelection | undefined,
91
+ ): string {
92
+ const lines = [
93
+ joinParts([
94
+ `Spawned ${result.agent_id} (turn ${result.turn_id}, ${result.status})`,
95
+ profile(result.model, result.thinking_level),
96
+ spawnToolsSummary(result.tools, requestedTools),
97
+ `delegation ${result.delegation}`,
98
+ ]),
99
+ ];
100
+ for (const warning of result.warnings ?? []) lines.push(`warning: ${warning}`);
101
+ return lines.join("\n");
102
+ }
103
+
104
+ /** One line naming how the message was delivered, or why it failed. */
105
+ export function formatAgentMessageResultText(result: AgentMessageResult): string {
106
+ switch (result.disposition) {
107
+ case "delivered-via-wait":
108
+ return `Message delivered to ${result.agent_id} through its active wait.`;
109
+ case "queued":
110
+ return `Message queued into ${result.agent_id}'s active turn.`;
111
+ case "started-turn":
112
+ return `${result.agent_id} was idle; the message started turn ${result.turn_id ?? "(unknown)"}. Wait on it with subagent_wait.`;
113
+ case "failed":
114
+ return `Message to ${result.agent_id} failed: ${result.error ?? "unknown error"}`;
115
+ }
116
+ }
117
+
118
+ function coordinationMessagesText(
119
+ agentId: string,
120
+ messages: readonly WaitMessageResult[] | undefined,
121
+ ): string | undefined {
122
+ if (!messages || messages.length === 0) return undefined;
123
+ return messages.map((message) => `Message from ${agentId}: ${message.message}`).join("\n");
124
+ }
125
+
126
+ /**
127
+ * One line saying an already-delivered result is not repeated and how to reread it, or that a
128
+ * handed result is still to arrive as a separate message, followed by any Coordination Messages
129
+ * the wait drained, which the parent has not seen yet.
130
+ */
131
+ function formatWaitDeliveredText(result: WaitDeliveredTurnResult): string {
132
+ const subject = `Result of ${result.agent_id} turn ${result.turn_id} (${result.status})`;
133
+ const notice = result.delivery_pending
134
+ ? `${subject} was handed to you automatically and arrives as a separate message; no reread is needed.`
135
+ : `${subject} was already delivered automatically; call subagent_wait with turn_id "${result.turn_id}" to reread it.`;
136
+ const messages = coordinationMessagesText(result.agent_id, result.messages);
137
+ return messages ? `${notice}\n${messages}` : notice;
138
+ }
139
+
140
+ /** A Coordination Message returned before its source turn settled. */
141
+ function formatWaitMessageText(result: WaitMessageResult): string {
142
+ return `Message from ${result.agent_id} (turn ${result.turn_id} still running):\n${result.message}`;
143
+ }
144
+
145
+ /** Status and elapsed time, one token/cost total, drained messages, then the turn's output. */
146
+ function formatWaitTurnText(result: WaitTurnResult): string {
147
+ const header = joinParts([
148
+ `${result.agent_id} turn ${result.turn_id} ${result.status}`,
149
+ formatSubagentDuration(result.elapsed_ms),
150
+ ]);
151
+ const total = usageTotal(result.usage);
152
+ const lines = [header];
153
+ if (total) lines.push(`usage: ${total}`);
154
+ if (result.error) lines.push(`error: ${result.error}`);
155
+ const messages = coordinationMessagesText(result.agent_id, result.messages);
156
+ if (messages) lines.push(messages);
157
+ return `${lines.join("\n")}\n\n${result.output || "(no output)"}`;
158
+ }
159
+
160
+ /** The observational timeout snapshot; the wait never cancels the child. */
161
+ function formatWaitTimeoutText(result: WaitTimeoutResult): string {
162
+ const lines = [
163
+ `Wait timed out after ${formatSubagentDuration(result.timeout_ms) ?? `${result.timeout_ms}ms`}; ${result.agent_id} is ${result.state} on turn ${result.turn_id} and was not cancelled.`,
164
+ ];
165
+ const progress = joinParts([
166
+ result.elapsed_ms === undefined
167
+ ? undefined
168
+ : `elapsed ${formatSubagentDuration(result.elapsed_ms)}`,
169
+ result.latest_activity_at ? `last activity ${result.latest_activity_at}` : undefined,
170
+ result.total_tokens === undefined
171
+ ? undefined
172
+ : `${formatSubagentTokenCount(result.total_tokens)} tokens`,
173
+ ]);
174
+ if (progress) lines.push(progress);
175
+ if (result.recent_activity_labels.length > 0) {
176
+ lines.push(`recent activity: ${result.recent_activity_labels.join(", ")}`);
177
+ }
178
+ return lines.join("\n");
179
+ }
180
+
181
+ /** Any Wait Event: a message, a settled or already-delivered turn, or a timeout. */
182
+ export function formatWaitResultText(result: WaitResult): string {
183
+ if ("already_delivered" in result) return formatWaitDeliveredText(result);
184
+ switch (result.event) {
185
+ case "message":
186
+ return formatWaitMessageText(result);
187
+ case "turn":
188
+ return formatWaitTurnText(result);
189
+ case "timeout":
190
+ return formatWaitTimeoutText(result);
191
+ }
192
+ }
193
+
194
+ function turnSummary(agent: AgentSummary): string {
195
+ if (agent.active_turn_id) return `turn ${agent.active_turn_id}`;
196
+ if (agent.latest_turn)
197
+ return `latest turn ${agent.latest_turn.turn_id} ${agent.latest_turn.status}`;
198
+ return "no turns";
199
+ }
200
+
201
+ function stateSummary(agent: AgentSummary): string {
202
+ return agent.availability === "unavailable" ? `${agent.state}, unavailable` : agent.state;
203
+ }
204
+
205
+ /** One line per direct child; nested summaries and tool lists stay in `details`. */
206
+ function formatStatusListText(parentId: string, agents: readonly AgentSummary[]): string {
207
+ if (agents.length === 0) return `${parentId} has no children.`;
208
+ const lines = [`${plural(agents.length, "child", "children")} of ${parentId}:`];
209
+ for (const agent of agents) {
210
+ lines.push(
211
+ `- ${joinParts([
212
+ `${agent.agent_id} ${stateSummary(agent)}`,
213
+ turnSummary(agent),
214
+ profile(agent.model, agent.thinking_level),
215
+ formatSubagentDuration(agent.elapsed_ms),
216
+ agent.child_count > 0 ? plural(agent.child_count, "child", "children") : undefined,
217
+ agent.task ? `task: ${formatSubagentPreview(agent.task, TASK_PREVIEW_WIDTH)}` : undefined,
218
+ ])}`,
219
+ );
220
+ }
221
+ return lines.join("\n");
222
+ }
223
+
224
+ /**
225
+ * Active tools, the Launch Contract grant, and the capability ceiling, each listed at most once:
226
+ * the grant and ceiling are described relative to the list before them.
227
+ */
228
+ function toolLines(agent: AgentDetail, verbose: boolean): string[] {
229
+ const active = agent.tools;
230
+ const granted = agent.launch_contract.ordinary_tools;
231
+ const ceiling = agent.capability_ceiling;
232
+ const lines = [`active tools (${active.length}): ${listOrNone(active)}`];
233
+
234
+ const inactiveGrants = without(granted, active);
235
+ const adapterTools = without(active, granted);
236
+ if (inactiveGrants.length === 0 && adapterTools.length === 0) {
237
+ lines.push("granted tools: same as active");
238
+ } else {
239
+ const differences = [
240
+ inactiveGrants.length > 0
241
+ ? `plus ${inactiveGrants.join(", ")} (granted but not active: codemode-only or undeclared)`
242
+ : undefined,
243
+ adapterTools.length > 0
244
+ ? `minus ${adapterTools.join(", ")} (active from a runtime adapter, not granted)`
245
+ : undefined,
246
+ ].filter(Boolean);
247
+ lines.push(`granted tools (${granted.length}): active ${differences.join("; ")}`);
248
+ }
249
+
250
+ const beyondGrant = without(ceiling, granted);
251
+ if (beyondGrant.length === 0) {
252
+ lines.push("capability ceiling: same as granted");
253
+ } else if (verbose) {
254
+ lines.push(`capability ceiling (${ceiling.length}): granted plus ${beyondGrant.join(", ")}`);
255
+ } else {
256
+ lines.push(
257
+ `capability ceiling: ${ceiling.length} tools, ${beyondGrant.length} beyond the grant`,
258
+ );
259
+ }
260
+ return lines;
261
+ }
262
+
263
+ function launchLine(agent: AgentDetail): string {
264
+ const contract = agent.launch_contract;
265
+ const tools =
266
+ contract.tools === undefined
267
+ ? "inherited tools"
268
+ : Array.isArray(contract.tools)
269
+ ? "explicit tool list"
270
+ : `${contract.tools} preset`;
271
+ const launchedProfile =
272
+ contract.model !== agent.model || contract.thinking_level !== agent.thinking_level
273
+ ? `launched as ${profile(contract.model, contract.thinking_level)}`
274
+ : undefined;
275
+ return `launch: ${joinParts([
276
+ contract.role ? `role ${contract.role}` : undefined,
277
+ launchedProfile,
278
+ tools,
279
+ `session context ${contract.session_context}`,
280
+ `project context ${contract.project_context}`,
281
+ `delegation ${contract.delegation ?? "none"}`,
282
+ ])}`;
283
+ }
284
+
285
+ function indent(text: string): string {
286
+ return text
287
+ .split("\n")
288
+ .map((line) => ` ${line}`)
289
+ .join("\n");
290
+ }
291
+
292
+ /**
293
+ * A summary line, then the Launch Contract, tools, latest result, and Recent Activity. Without
294
+ * `verbose` the task, result, and activity are one-line previews and the ceiling is a count.
295
+ */
296
+ function formatStatusDetailText(agent: AgentDetail, verbose: boolean): string {
297
+ const lines = [
298
+ joinParts([
299
+ `${agent.agent_id} ${stateSummary(agent)}`,
300
+ turnSummary(agent),
301
+ profile(agent.model, agent.thinking_level),
302
+ agent.elapsed_ms === undefined
303
+ ? undefined
304
+ : `elapsed ${formatSubagentDuration(agent.elapsed_ms)}`,
305
+ agent.latest_activity_at ? `last activity ${agent.latest_activity_at}` : undefined,
306
+ usageTotal(agent.usage),
307
+ ]),
308
+ joinParts([`parent ${agent.parent_id}`, plural(agent.child_count, "child", "children")]),
309
+ ];
310
+ if (agent.unavailable_reason) lines.push(`unavailable: ${agent.unavailable_reason}`);
311
+ if (agent.missing_dependencies.length > 0) {
312
+ lines.push(`missing dependencies: ${agent.missing_dependencies.join(", ")}`);
313
+ }
314
+ if (agent.task) {
315
+ lines.push(
316
+ verbose
317
+ ? `task:\n${indent(agent.task)}`
318
+ : `task: ${formatSubagentPreview(agent.task, TASK_PREVIEW_WIDTH)}`,
319
+ );
320
+ }
321
+ lines.push(launchLine(agent), ...toolLines(agent, verbose));
322
+
323
+ const latest = agent.latest_result;
324
+ if (latest) {
325
+ // The summary line already names an idle child's latest turn.
326
+ const heading = joinParts([
327
+ agent.active_turn_id
328
+ ? `latest result: turn ${latest.turn_id} ${latest.status}`
329
+ : "latest result",
330
+ formatSubagentDuration(latest.elapsed_ms),
331
+ ]);
332
+ if (latest.error) lines.push(`${heading}\n error: ${latest.error}`);
333
+ else lines.push(heading);
334
+ if (latest.output) {
335
+ lines.push(
336
+ verbose
337
+ ? indent(latest.output)
338
+ : ` ${formatSubagentPreview(latest.output, RESULT_PREVIEW_WIDTH)}`,
339
+ );
340
+ }
341
+ }
342
+
343
+ if (verbose) {
344
+ const usage = formatSubagentUsage(agent.usage);
345
+ if (usage) lines.push(`usage: ${usage}`);
346
+ if (agent.session_file) lines.push(`session file: ${agent.session_file}`);
347
+ lines.push(`spawn entry: ${agent.spawn_entry_id}`);
348
+ if (agent.recent_messages.length > 0) {
349
+ lines.push(`recent messages (${agent.recent_messages.length}):`);
350
+ for (const message of agent.recent_messages) {
351
+ lines.push(
352
+ `- ${message.source_agent_id} (${message.turn_id}):\n${indent(message.content)}`,
353
+ );
354
+ }
355
+ }
356
+ if (agent.recent_activity.length > 0) {
357
+ lines.push(`recent activity (${agent.recent_activity.length}):`);
358
+ for (const activity of agent.recent_activity) {
359
+ const label = `${activity.label}${activity.truncated ? " (truncated)" : ""}`;
360
+ lines.push(`- ${label}:\n${indent(activity.content)}`);
361
+ }
362
+ }
363
+ return lines.join("\n");
364
+ }
365
+
366
+ const activity = agent.recent_activity.slice(-STATUS_ACTIVITY_PREVIEW_COUNT);
367
+ if (activity.length > 0) {
368
+ lines.push(
369
+ `recent activity (last ${activity.length} of ${agent.recent_activity.length}):`,
370
+ ...activity.map(
371
+ (item) => `- ${item.label}: ${formatSubagentPreview(item.content, ACTIVITY_PREVIEW_WIDTH)}`,
372
+ ),
373
+ );
374
+ }
375
+ lines.push(
376
+ "Pass verbose: true for the full task, latest output, capability ceiling, recent messages, and recent activity.",
377
+ );
378
+ return lines.join("\n");
379
+ }
380
+
381
+ /** A direct-children list or one child's detail. */
382
+ export function formatStatusResultText(result: StatusResult, verbose = false): string {
383
+ return "agent" in result
384
+ ? formatStatusDetailText(result.agent, verbose)
385
+ : formatStatusListText(result.parent_id, result.agents);
386
+ }
387
+
388
+ /** One line naming the cancelled turns, or that no turn was running. */
389
+ export function formatCancelResultText(result: CancelResult): string {
390
+ if (result.cancelled_turn_ids.length === 0) {
391
+ return `Nothing cancelled: ${result.agent_id} had no active turn${result.recursive ? ", nor did any descendant" : ""}.`;
392
+ }
393
+ return `Cancelled ${plural(result.cancelled_turn_ids.length, "active turn")}: ${result.cancelled_turn_ids.join(", ")}; sessions are kept.`;
394
+ }
395
+
396
+ /** One line naming the deleted agents, plus one line per failure. */
397
+ export function formatDeleteResultText(result: DeleteResult): string {
398
+ const lines =
399
+ result.deleted_agent_ids.length === 0
400
+ ? [`Deleted no agents under ${result.agent_id}.`]
401
+ : [
402
+ `Deleted ${result.deleted_agent_ids.join(", ")}; ${plural(result.trashed_session_files.length, "session file")} moved to trash.`,
403
+ ];
404
+ for (const failure of result.failures) {
405
+ lines.push(`failed: ${failure.agent_id}: ${failure.error}`);
406
+ }
407
+ return lines.join("\n");
408
+ }
@@ -21,6 +21,12 @@ import {
21
21
  type SessionEntry,
22
22
  type ToolDefinition,
23
23
  } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ AGENT_LINEAGE_ENTRY_TYPE,
26
+ agentLineageRecord,
27
+ readAgentLineage,
28
+ type AgentLineage,
29
+ } from "@ian-pascoe/pi-utils/agent-lineage";
24
30
  import type { Static, TSchema } from "typebox";
25
31
  import { Value } from "typebox/value";
26
32
  import {
@@ -284,6 +290,20 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
284
290
  sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
285
291
  }
286
292
 
293
+ /** The shared Agent Lineage of a child session, readable by packages that do not import this one. */
294
+ function agentLineage(
295
+ agent: PersistedAgent,
296
+ rootSessionId: string,
297
+ sessionId: string,
298
+ ): AgentLineage {
299
+ return {
300
+ sessionId,
301
+ rootSessionId,
302
+ agentId: agent.agent_id,
303
+ parentAgentId: agent.parent_id === "root" ? null : agent.parent_id,
304
+ };
305
+ }
306
+
287
307
  /** Create and force-flush a writable child JSONL identity before its first model response. */
288
308
  export function createPersistentChildIdentity(
289
309
  options: PersistentIdentityOptions,
@@ -296,6 +316,12 @@ export function createPersistentChildIdentity(
296
316
  direct_parent_id: options.agent.parent_id,
297
317
  created_at: options.agent.created_at,
298
318
  });
319
+ sessionManager.appendCustomEntry(
320
+ AGENT_LINEAGE_ENTRY_TYPE,
321
+ agentLineageRecord(
322
+ agentLineage(options.agent, options.rootSessionId, sessionManager.getSessionId()),
323
+ ),
324
+ );
299
325
  sessionManager.appendSessionInfo(`[subagent] ${options.agent.agent_id}`);
300
326
  sessionManager.appendModelChange(
301
327
  options.agent.launch_contract.model.slice(0, options.agent.launch_contract.model.indexOf("/")),
@@ -1043,6 +1069,15 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1043
1069
  direct_parent_id: agent.parent_id,
1044
1070
  });
1045
1071
  }
1072
+ // A clone inherits its source's record, which names the source session; record this one's own.
1073
+ const lineage = agentLineage(agent, this.options.rootSessionId, sessionManager.getSessionId());
1074
+ const recorded = readAgentLineage(lineage.sessionId, sessionManager.getBranch());
1075
+ if (
1076
+ recorded?.rootSessionId !== lineage.rootSessionId ||
1077
+ recorded.agentId !== lineage.agentId ||
1078
+ recorded.parentAgentId !== lineage.parentAgentId
1079
+ )
1080
+ sessionManager.appendCustomEntry(AGENT_LINEAGE_ENTRY_TYPE, agentLineageRecord(lineage));
1046
1081
  return {
1047
1082
  sessionFile,
1048
1083
  sessionId: sessionManager.getSessionId(),
@@ -13,6 +13,8 @@ import {
13
13
  type Component,
14
14
  type OverlayHandle,
15
15
  type TUI,
16
+ type TuiMouseEvent,
17
+ type TuiMouseEventResult,
16
18
  } from "@earendil-works/pi-tui";
17
19
  import { hintLine, joinInline, noticeText } from "@ian-pascoe/pi-utils/ui";
18
20
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
@@ -143,6 +145,9 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
143
145
  private transcriptLayout?: TranscriptLayout;
144
146
  private readonly transcriptCache = createTranscriptRenderCache();
145
147
  private bodyHeight = 1;
148
+ /** Panel row of the first body line in the last render; undefined when no body was drawn. */
149
+ private bodyTop?: number;
150
+ private mousePressedAgentId?: string;
146
151
  private ensureSelectionVisible = true;
147
152
  private toolOutputExpanded = false;
148
153
  private disposed = false;
@@ -204,6 +209,50 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
204
209
  this.tui.requestRender();
205
210
  }
206
211
 
212
+ /**
213
+ * Handle the mouse like Pi's SelectList. The wheel mirrors the arrow keys: one row of selection
214
+ * per event in the tree, the wheel's logical lines in the transcript. In the tree, pressing a
215
+ * row selects it and clicking it opens its transcript. Other events stay unhandled so Pi's
216
+ * screen text selection still copies transcript text.
217
+ */
218
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
219
+ if (event.type === "wheel") {
220
+ if (!event.wheelDelta) return undefined;
221
+ this.moveSelection(
222
+ this.view === "transcript" ? event.wheelDelta : Math.sign(event.wheelDelta),
223
+ );
224
+ return { handled: true };
225
+ }
226
+ if (this.view !== "tree" || event.button !== "left") return undefined;
227
+ if (event.type === "press") {
228
+ const agentId = this.agentIdAtRow(event.y);
229
+ this.mousePressedAgentId = agentId;
230
+ if (!agentId) return undefined;
231
+ this.selectedAgentId = agentId;
232
+ this.ensureSelectionVisible = true;
233
+ return { handled: true, focus: true };
234
+ }
235
+ if (event.type === "click") {
236
+ const agentId = this.mousePressedAgentId ?? this.agentIdAtRow(event.y);
237
+ this.mousePressedAgentId = undefined;
238
+ if (!agentId || !this.flattened.some(({ agent }) => agent.agent_id === agentId)) {
239
+ return undefined;
240
+ }
241
+ this.selectedAgentId = agentId;
242
+ this.openSelectedTranscript();
243
+ return { handled: true };
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ /** The Child Agent drawn on a panel row in the tree, if any. */
249
+ private agentIdAtRow(row: number): string | undefined {
250
+ if (this.bodyTop === undefined) return undefined;
251
+ const bodyRow = row - this.bodyTop;
252
+ if (bodyRow < 0 || bodyRow >= this.bodyHeight) return undefined;
253
+ return this.flattened[this.scrollOffset + bodyRow]?.agent.agent_id;
254
+ }
255
+
207
256
  /** Render the tree or Child Session Transcript in Pi's selector frame, within the terminal. */
208
257
  render(width: number): string[] {
209
258
  if (width <= 0) return [];
@@ -214,6 +263,7 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
214
263
  this.tui.terminal.rows - 2 * STATUS_PANEL_MARGIN,
215
264
  ),
216
265
  );
266
+ this.bodyTop = undefined;
217
267
  if (width < 6 || height < 5) {
218
268
  const cancelKey = this.keybindings.getKeys("tui.select.cancel").join("/");
219
269
  return new Text(this.theme.fg("muted", `${cancelKey} back · Enlarge terminal`), 0, 0)
@@ -235,6 +285,8 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
235
285
  ].slice(0, Math.min(3, height - 4));
236
286
  const visibleHeader = header.slice(0, Math.max(1, height - help.length - 3));
237
287
  this.bodyHeight = Math.max(1, height - 2 - visibleHeader.length - help.length);
288
+ // The top border precedes the header.
289
+ this.bodyTop = 1 + visibleHeader.length;
238
290
  let body: string[];
239
291
  if (transcriptView) {
240
292
  const layout = renderTranscriptSnapshot(
@@ -87,6 +87,12 @@ export function createCoordinatorToolSchemas(modelIds: readonly string[]) {
87
87
  agent_id: Type.Optional(
88
88
  canonicalAgentIdSchema("Direct child canonical agent ID; the root may name any descendant"),
89
89
  ),
90
+ verbose: Type.Optional(
91
+ Type.Boolean({
92
+ description:
93
+ "With agent_id: include the full task, latest output, capability ceiling, usage breakdown, recent messages, and all recent activity",
94
+ }),
95
+ ),
90
96
  }),
91
97
  subagent_cancel: Type.Object({
92
98
  agent_id: canonicalAgentIdSchema("Direct child canonical agent ID"),
@@ -16,6 +16,14 @@ import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinato
16
16
  import { withTroubleshootingHint } from "./troubleshooting-skill.js";
17
17
  import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
18
18
  import { roundMinimalSubagentsUsageCosts } from "./minimal-subagents-usage.js";
19
+ import {
20
+ formatAgentMessageResultText,
21
+ formatCancelResultText,
22
+ formatDeleteResultText,
23
+ formatSpawnResultText,
24
+ formatStatusResultText,
25
+ formatWaitResultText,
26
+ } from "./minimal-subagents-result-text.js";
19
27
  import {
20
28
  renderCoordinatorToolCall,
21
29
  renderCoordinatorToolResult,
@@ -41,7 +49,6 @@ import type {
41
49
  DeleteResult,
42
50
  SpawnResult,
43
51
  StatusResult,
44
- WaitDeliveredTurnResult,
45
52
  WaitResult,
46
53
  } from "./minimal-subagents-types.js";
47
54
 
@@ -203,49 +210,40 @@ function createCoordinatorToolRendering(
203
210
  * Serialize a tool result with every nested `usage` cost rounded for presentation. Exact values
204
211
  * stay in `details`, the Registry, and session data, which never pass through here.
205
212
  */
206
- function stringifyWithRoundedUsageCosts(
207
- result: CoordinatorToolResultDetails,
208
- space?: number,
209
- ): string {
213
+ function stringifyWithRoundedUsageCosts(result: CoordinatorToolResultDetails): string {
210
214
  // `JSON.stringify` types replacer values as `any`; a usage is recognized by key and `cost` object.
211
- return JSON.stringify(
212
- result,
213
- (key, value) =>
214
- key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
215
- space,
215
+ return JSON.stringify(result, (key, value) =>
216
+ key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
216
217
  );
217
218
  }
218
219
 
219
- function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
220
- result: TDetails,
221
- ): AgentToolResult<TDetails> {
222
- // Presentation only: `details` keeps the exact values the session persists.
223
- const json = stringifyWithRoundedUsageCosts(result, 2);
224
- const truncated = truncateHead(json, {
220
+ /** Model-facing text, truncated like Pi's built-in tool output. */
221
+ function textContent(text: string): AgentToolResult<unknown>["content"] {
222
+ const truncated = truncateHead(text, {
225
223
  maxBytes: DEFAULT_MAX_BYTES,
226
224
  maxLines: DEFAULT_MAX_LINES,
227
225
  });
226
+ return [{ type: "text" as const, text: truncated.content }];
227
+ }
228
+
229
+ /**
230
+ * The model reads compact `text`; codemode scripts and the transcript renderer keep the complete
231
+ * record in `structuredContent` (costs rounded) and `details` (exact).
232
+ */
233
+ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
234
+ result: TDetails,
235
+ text: string,
236
+ ): AgentToolResult<TDetails> {
228
237
  return {
229
- content: [{ type: "text" as const, text: truncated.content }],
238
+ content: textContent(text),
230
239
  details: result,
231
- structuredContent: JSON.parse(json),
240
+ structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(result)),
232
241
  };
233
242
  }
234
243
 
235
- /**
236
- * One line saying an already-delivered result is not repeated and how to reread it, followed by
237
- * any Coordination Messages the wait drained, which the parent has not seen yet.
238
- */
239
- function alreadyDeliveredContent(
240
- result: WaitDeliveredTurnResult,
241
- ): AgentToolResult<WaitResult>["content"] {
242
- const notice = `Result of ${result.agent_id} turn ${result.turn_id} (${result.status}) was already delivered automatically; call subagent_wait with turn_id "${result.turn_id}" to reread it.`;
243
- if (!result.messages) return [{ type: "text", text: notice }];
244
- const messages = truncateHead(JSON.stringify({ messages: result.messages }, null, 2), {
245
- maxBytes: DEFAULT_MAX_BYTES,
246
- maxLines: DEFAULT_MAX_LINES,
247
- });
248
- return [{ type: "text", text: `${notice}\n${messages.content}` }];
244
+ /** One wait result as model-facing text. */
245
+ function waitResultText(result: WaitResult): AgentToolResult<WaitResult>["content"] {
246
+ return textContent(formatWaitResultText(result));
249
247
  }
250
248
 
251
249
  /**
@@ -255,26 +253,19 @@ function alreadyDeliveredContent(
255
253
  function failedStructuredToolResult<TDetails extends CoordinatorToolResultDetails>(
256
254
  prefix: string,
257
255
  result: TDetails,
256
+ resultText: string,
258
257
  options: { troubleshootingHint: boolean },
259
258
  ): AgentToolResult<TDetails> & { isError: true } {
260
- const success = structuredToolResult(result);
261
- const text = `${prefix}:\n${textOf(success)}`;
259
+ const text = `${prefix}:\n${resultText}`;
262
260
  return {
263
- ...success,
264
- content: [
265
- {
266
- type: "text" as const,
267
- text: options.troubleshootingHint ? withTroubleshootingHint(text) : text,
268
- },
269
- ],
261
+ ...structuredToolResult(
262
+ result,
263
+ options.troubleshootingHint ? withTroubleshootingHint(text) : text,
264
+ ),
270
265
  isError: true,
271
266
  };
272
267
  }
273
268
 
274
- function textOf(result: AgentToolResult<unknown>): string {
275
- return result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
276
- }
277
-
278
269
  function callerSourceTurnId(
279
270
  coordinator: CoordinatorToolOperations,
280
271
  callerId: string,
@@ -320,7 +311,10 @@ export function createCoordinatorToolDefinitions(
320
311
  const status = options.coordinator.inspectStatus(result.agent_id);
321
312
  const details: SpawnResultDetails = { ...result };
322
313
  if ("agent" in status) details.agent = status.agent;
323
- return { ...structuredToolResult(result), details };
314
+ return {
315
+ ...structuredToolResult(result, formatSpawnResultText(result, parameters.tools)),
316
+ details,
317
+ };
324
318
  });
325
319
  },
326
320
  ...createCoordinatorToolRendering(options, "subagent"),
@@ -349,12 +343,13 @@ export function createCoordinatorToolDefinitions(
349
343
  },
350
344
  callerSourceTurnId(options.coordinator, options.callerId, toolCallId),
351
345
  );
346
+ const text = formatAgentMessageResultText(result);
352
347
  return result.disposition === "failed"
353
- ? failedStructuredToolResult("Minimal subagents message delivery failed", result, {
348
+ ? failedStructuredToolResult("Minimal subagents message delivery failed", result, text, {
354
349
  // The coordinator already appends the hint to `error`.
355
350
  troubleshootingHint: false,
356
351
  })
357
- : structuredToolResult(result);
352
+ : structuredToolResult(result, text);
358
353
  });
359
354
  },
360
355
  ...createCoordinatorToolRendering(options, "agent_message"),
@@ -364,7 +359,7 @@ export function createCoordinatorToolDefinitions(
364
359
  name: "subagent_wait",
365
360
  label: "Subagent Wait",
366
361
  description:
367
- "Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns a result you claimed in full. Without turn_id, a result already delivered to you automatically returns event=turn with already_delivered=true and no output; pass its turn_id to reread it. Timeout returns a compact event=timeout (state, elapsed_ms, latest_activity_at, total_tokens, last few activity labels; use subagent_status for detail) and never cancels the child.",
362
+ "Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns a result you claimed in full. Without turn_id, a result already delivered to you automatically returns event=turn with already_delivered=true and no output; pass its turn_id to reread it. If it was handed to you but is not yet in your conversation, delivery_pending=true and it arrives as a separate message; do not reread. Timeout returns a compact event=timeout (state, elapsed_ms, latest_activity_at, total_tokens, last few activity labels; use subagent_status for detail) and never cancels the child.",
368
363
  promptSnippet: "Wait for one direct child's exact turn",
369
364
  parameters: options.schemas.subagent_wait,
370
365
  annotations: {
@@ -409,10 +404,7 @@ export function createCoordinatorToolDefinitions(
409
404
  source_turn_id: result.turn_id,
410
405
  };
411
406
  return {
412
- content:
413
- "already_delivered" in result
414
- ? alreadyDeliveredContent(result)
415
- : structuredToolResult(result).content,
407
+ content: waitResultText(result),
416
408
  details,
417
409
  structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(details)),
418
410
  };
@@ -428,7 +420,7 @@ export function createCoordinatorToolDefinitions(
428
420
  name: "subagent_status",
429
421
  label: "Subagent Status",
430
422
  description:
431
- "List direct children when agent_id is omitted, or inspect one direct child's launch contract, result, usage, dependencies, and bounded recent activity including message text and reasoning. The root may inspect any descendant.",
423
+ "List direct children when agent_id is omitted, or inspect one direct child's launch contract, result, usage, dependencies, and bounded recent activity including message text and reasoning. Without verbose, the task, latest output, and recent activity are short previews. The root may inspect any descendant.",
432
424
  promptSnippet: "Inspect direct child state",
433
425
  parameters: options.schemas.subagent_status,
434
426
  annotations: {
@@ -438,9 +430,13 @@ export function createCoordinatorToolDefinitions(
438
430
  openWorldHint: false,
439
431
  },
440
432
  async execute(_toolCallId, parameters) {
441
- return runCoordinatorToolActivity(options, () =>
442
- structuredToolResult(options.coordinator.status(options.callerId, parameters.agent_id)),
443
- );
433
+ return runCoordinatorToolActivity(options, () => {
434
+ const result = options.coordinator.status(options.callerId, parameters.agent_id);
435
+ return structuredToolResult(
436
+ result,
437
+ formatStatusResultText(result, parameters.verbose ?? false),
438
+ );
439
+ });
444
440
  },
445
441
  ...createCoordinatorToolRendering(options, "subagent_status"),
446
442
  });
@@ -459,15 +455,14 @@ export function createCoordinatorToolDefinitions(
459
455
  openWorldHint: false,
460
456
  },
461
457
  async execute(_toolCallId, parameters) {
462
- return runCoordinatorToolActivity(options, async () =>
463
- structuredToolResult(
464
- await options.coordinator.cancel(
465
- options.callerId,
466
- parameters.agent_id,
467
- parameters.recursive ?? true,
468
- ),
469
- ),
470
- );
458
+ return runCoordinatorToolActivity(options, async () => {
459
+ const result = await options.coordinator.cancel(
460
+ options.callerId,
461
+ parameters.agent_id,
462
+ parameters.recursive ?? true,
463
+ );
464
+ return structuredToolResult(result, formatCancelResultText(result));
465
+ });
471
466
  },
472
467
  ...createCoordinatorToolRendering(options, "subagent_cancel"),
473
468
  });
@@ -492,13 +487,17 @@ export function createCoordinatorToolDefinitions(
492
487
  parameters.agent_id,
493
488
  parameters.recursive ?? true,
494
489
  );
490
+ const text = formatDeleteResultText(result);
495
491
  if (result.failures.length > 0) {
496
492
  options.onAttention?.(`Deletion partially failed for ${parameters.agent_id}`);
497
- return failedStructuredToolResult("Minimal subagents deletion partially failed", result, {
498
- troubleshootingHint: true,
499
- });
493
+ return failedStructuredToolResult(
494
+ "Minimal subagents deletion partially failed",
495
+ result,
496
+ text,
497
+ { troubleshootingHint: true },
498
+ );
500
499
  }
501
- return structuredToolResult(result);
500
+ return structuredToolResult(result, text);
502
501
  });
503
502
  },
504
503
  ...createCoordinatorToolRendering(options, "subagent_delete"),
@@ -98,6 +98,9 @@ export interface WaitTurnResult extends TurnResult {
98
98
  messages?: WaitMessageResult[];
99
99
  }
100
100
 
101
+ /** How a result reached the caller automatically: handed to Pi but not yet in its branch, or recorded there. */
102
+ export type AutomaticDelivery = "handed" | "recorded";
103
+
101
104
  /**
102
105
  * Reports that a default wait selected a terminal turn whose result was already delivered
103
106
  * automatically, handed or settled, so the output is not repeated; an explicit `turn_id` rereads it.
@@ -108,6 +111,11 @@ export interface WaitDeliveredTurnResult extends Pick<
108
111
  > {
109
112
  event: "turn";
110
113
  already_delivered: true;
114
+ /**
115
+ * Set when the result is handed to Pi but not yet recorded in the caller's branch, so it will
116
+ * arrive as a separate message and needs no reread.
117
+ */
118
+ delivery_pending?: true;
111
119
  messages?: WaitMessageResult[];
112
120
  }
113
121