@ian-pascoe/pi-minimal-subagents 0.17.0 → 0.18.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"`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.5.0",
34
+ "@ian-pascoe/pi-utils": "^0.6.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
+ }
@@ -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"),
@@ -10,11 +10,20 @@ import {
10
10
  type ToolRenderResultOptions,
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import { Container, type Component, type TUI } from "@earendil-works/pi-tui";
13
+ import { acceptNullForOptionalArguments } from "@ian-pascoe/pi-utils/null-optional-arguments";
13
14
  import { appendDurationFooter, callDurationFooter } from "@ian-pascoe/pi-utils/ui";
14
15
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
15
16
  import { withTroubleshootingHint } from "./troubleshooting-skill.js";
16
17
  import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
17
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";
18
27
  import {
19
28
  renderCoordinatorToolCall,
20
29
  renderCoordinatorToolResult,
@@ -40,7 +49,6 @@ import type {
40
49
  DeleteResult,
41
50
  SpawnResult,
42
51
  StatusResult,
43
- WaitDeliveredTurnResult,
44
52
  WaitResult,
45
53
  } from "./minimal-subagents-types.js";
46
54
 
@@ -202,49 +210,40 @@ function createCoordinatorToolRendering(
202
210
  * Serialize a tool result with every nested `usage` cost rounded for presentation. Exact values
203
211
  * stay in `details`, the Registry, and session data, which never pass through here.
204
212
  */
205
- function stringifyWithRoundedUsageCosts(
206
- result: CoordinatorToolResultDetails,
207
- space?: number,
208
- ): string {
213
+ function stringifyWithRoundedUsageCosts(result: CoordinatorToolResultDetails): string {
209
214
  // `JSON.stringify` types replacer values as `any`; a usage is recognized by key and `cost` object.
210
- return JSON.stringify(
211
- result,
212
- (key, value) =>
213
- key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
214
- space,
215
+ return JSON.stringify(result, (key, value) =>
216
+ key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
215
217
  );
216
218
  }
217
219
 
218
- function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
219
- result: TDetails,
220
- ): AgentToolResult<TDetails> {
221
- // Presentation only: `details` keeps the exact values the session persists.
222
- const json = stringifyWithRoundedUsageCosts(result, 2);
223
- 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, {
224
223
  maxBytes: DEFAULT_MAX_BYTES,
225
224
  maxLines: DEFAULT_MAX_LINES,
226
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> {
227
237
  return {
228
- content: [{ type: "text" as const, text: truncated.content }],
238
+ content: textContent(text),
229
239
  details: result,
230
- structuredContent: JSON.parse(json),
240
+ structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(result)),
231
241
  };
232
242
  }
233
243
 
234
- /**
235
- * One line saying an already-delivered result is not repeated and how to reread it, followed by
236
- * any Coordination Messages the wait drained, which the parent has not seen yet.
237
- */
238
- function alreadyDeliveredContent(
239
- result: WaitDeliveredTurnResult,
240
- ): AgentToolResult<WaitResult>["content"] {
241
- 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.`;
242
- if (!result.messages) return [{ type: "text", text: notice }];
243
- const messages = truncateHead(JSON.stringify({ messages: result.messages }, null, 2), {
244
- maxBytes: DEFAULT_MAX_BYTES,
245
- maxLines: DEFAULT_MAX_LINES,
246
- });
247
- 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));
248
247
  }
249
248
 
250
249
  /**
@@ -254,26 +253,19 @@ function alreadyDeliveredContent(
254
253
  function failedStructuredToolResult<TDetails extends CoordinatorToolResultDetails>(
255
254
  prefix: string,
256
255
  result: TDetails,
256
+ resultText: string,
257
257
  options: { troubleshootingHint: boolean },
258
258
  ): AgentToolResult<TDetails> & { isError: true } {
259
- const success = structuredToolResult(result);
260
- const text = `${prefix}:\n${textOf(success)}`;
259
+ const text = `${prefix}:\n${resultText}`;
261
260
  return {
262
- ...success,
263
- content: [
264
- {
265
- type: "text" as const,
266
- text: options.troubleshootingHint ? withTroubleshootingHint(text) : text,
267
- },
268
- ],
261
+ ...structuredToolResult(
262
+ result,
263
+ options.troubleshootingHint ? withTroubleshootingHint(text) : text,
264
+ ),
269
265
  isError: true,
270
266
  };
271
267
  }
272
268
 
273
- function textOf(result: AgentToolResult<unknown>): string {
274
- return result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
275
- }
276
-
277
269
  function callerSourceTurnId(
278
270
  coordinator: CoordinatorToolOperations,
279
271
  callerId: string,
@@ -319,7 +311,10 @@ export function createCoordinatorToolDefinitions(
319
311
  const status = options.coordinator.inspectStatus(result.agent_id);
320
312
  const details: SpawnResultDetails = { ...result };
321
313
  if ("agent" in status) details.agent = status.agent;
322
- return { ...structuredToolResult(result), details };
314
+ return {
315
+ ...structuredToolResult(result, formatSpawnResultText(result, parameters.tools)),
316
+ details,
317
+ };
323
318
  });
324
319
  },
325
320
  ...createCoordinatorToolRendering(options, "subagent"),
@@ -348,12 +343,13 @@ export function createCoordinatorToolDefinitions(
348
343
  },
349
344
  callerSourceTurnId(options.coordinator, options.callerId, toolCallId),
350
345
  );
346
+ const text = formatAgentMessageResultText(result);
351
347
  return result.disposition === "failed"
352
- ? failedStructuredToolResult("Minimal subagents message delivery failed", result, {
348
+ ? failedStructuredToolResult("Minimal subagents message delivery failed", result, text, {
353
349
  // The coordinator already appends the hint to `error`.
354
350
  troubleshootingHint: false,
355
351
  })
356
- : structuredToolResult(result);
352
+ : structuredToolResult(result, text);
357
353
  });
358
354
  },
359
355
  ...createCoordinatorToolRendering(options, "agent_message"),
@@ -363,7 +359,7 @@ export function createCoordinatorToolDefinitions(
363
359
  name: "subagent_wait",
364
360
  label: "Subagent Wait",
365
361
  description:
366
- "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.",
367
363
  promptSnippet: "Wait for one direct child's exact turn",
368
364
  parameters: options.schemas.subagent_wait,
369
365
  annotations: {
@@ -408,10 +404,7 @@ export function createCoordinatorToolDefinitions(
408
404
  source_turn_id: result.turn_id,
409
405
  };
410
406
  return {
411
- content:
412
- "already_delivered" in result
413
- ? alreadyDeliveredContent(result)
414
- : structuredToolResult(result).content,
407
+ content: waitResultText(result),
415
408
  details,
416
409
  structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(details)),
417
410
  };
@@ -427,7 +420,7 @@ export function createCoordinatorToolDefinitions(
427
420
  name: "subagent_status",
428
421
  label: "Subagent Status",
429
422
  description:
430
- "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.",
431
424
  promptSnippet: "Inspect direct child state",
432
425
  parameters: options.schemas.subagent_status,
433
426
  annotations: {
@@ -437,9 +430,13 @@ export function createCoordinatorToolDefinitions(
437
430
  openWorldHint: false,
438
431
  },
439
432
  async execute(_toolCallId, parameters) {
440
- return runCoordinatorToolActivity(options, () =>
441
- structuredToolResult(options.coordinator.status(options.callerId, parameters.agent_id)),
442
- );
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
+ });
443
440
  },
444
441
  ...createCoordinatorToolRendering(options, "subagent_status"),
445
442
  });
@@ -458,15 +455,14 @@ export function createCoordinatorToolDefinitions(
458
455
  openWorldHint: false,
459
456
  },
460
457
  async execute(_toolCallId, parameters) {
461
- return runCoordinatorToolActivity(options, async () =>
462
- structuredToolResult(
463
- await options.coordinator.cancel(
464
- options.callerId,
465
- parameters.agent_id,
466
- parameters.recursive ?? true,
467
- ),
468
- ),
469
- );
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
+ });
470
466
  },
471
467
  ...createCoordinatorToolRendering(options, "subagent_cancel"),
472
468
  });
@@ -491,13 +487,17 @@ export function createCoordinatorToolDefinitions(
491
487
  parameters.agent_id,
492
488
  parameters.recursive ?? true,
493
489
  );
490
+ const text = formatDeleteResultText(result);
494
491
  if (result.failures.length > 0) {
495
492
  options.onAttention?.(`Deletion partially failed for ${parameters.agent_id}`);
496
- return failedStructuredToolResult("Minimal subagents deletion partially failed", result, {
497
- troubleshootingHint: true,
498
- });
493
+ return failedStructuredToolResult(
494
+ "Minimal subagents deletion partially failed",
495
+ result,
496
+ text,
497
+ { troubleshootingHint: true },
498
+ );
499
499
  }
500
- return structuredToolResult(result);
500
+ return structuredToolResult(result, text);
501
501
  });
502
502
  },
503
503
  ...createCoordinatorToolRendering(options, "subagent_delete"),
@@ -512,7 +512,9 @@ export function createCoordinatorToolDefinitions(
512
512
  Object.assign(deleteTool, { outputSchema: CoordinatorToolOutputSchemas.subagent_delete }),
513
513
  ];
514
514
  const allowFanoutTools = options.allowFanoutTools ?? options.callerId === "root";
515
- return allowFanoutTools
516
- ? coordinatorTools
517
- : coordinatorTools.filter((tool) => NON_SPAWNING_CHILD_COORDINATOR_TOOL_NAMES.has(tool.name));
515
+ return (
516
+ allowFanoutTools
517
+ ? coordinatorTools
518
+ : coordinatorTools.filter((tool) => NON_SPAWNING_CHILD_COORDINATOR_TOOL_NAMES.has(tool.name))
519
+ ).map((tool) => acceptNullForOptionalArguments(tool));
518
520
  }
@@ -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