@ian-pascoe/pi-minimal-subagents 0.2.3 → 0.4.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
@@ -130,6 +130,11 @@ children: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
130
130
  only the three adjacent-coordination tools: `agent_message`, `subagent_wait`,
131
131
  and `subagent_status`.
132
132
 
133
+ Targeted `subagent_status` includes `recent_activity`, a bounded tail of message
134
+ text, reasoning, tool calls, and tool results. It includes the current streaming
135
+ assistant message but omits image data. Timeout Wait Events include the same
136
+ detailed status snapshot.
137
+
133
138
  The `subagent` `tools` argument distinguishes capability presets from exact
134
139
  lists: `"read"` grants `read`, `grep`, `find`, and `ls`; `"modify"` adds
135
140
  `bash`, `edit`, and `write`; an array such as `["read"]` grants exactly the
@@ -148,6 +153,10 @@ optional `turn_id` to address an older retained turn exactly. Without it, waits
148
153
  select the oldest observable claimed or pending turn before the active/latest
149
154
  turn. A caller may have only one outstanding wait for the same source turn; a
150
155
  concurrent duplicate is rejected instead of competing for one Wait Event.
156
+ When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
157
+ with the requested turn identity and the same detailed Child Agent status used
158
+ by targeted `subagent_status`. It removes only the waiter, leaving the child
159
+ running and all pending delivery unclaimed. Abort signals remain errors.
151
160
 
152
161
  The persisted Delivery Ledger records Coordination Messages, terminal results,
153
162
  globally increasing sequence, and wait ownership before delivery. Existing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "files": [
23
23
  "src",
24
+ "skills",
24
25
  "README.md",
25
26
  "LICENSE"
26
27
  ],
@@ -42,6 +43,9 @@
42
43
  "pi": {
43
44
  "extensions": [
44
45
  "./src/index.ts"
46
+ ],
47
+ "skills": [
48
+ "./skills"
45
49
  ]
46
50
  },
47
51
  "scripts": {
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: pi-minimal-subagents
3
+ description: Configure or diagnose Pi Minimal Subagents for spawn, capability, delivery, wait, restore, reload, or fork failures.
4
+ license: MIT
5
+ ---
6
+
7
+ # Pi Minimal Subagents
8
+
9
+ 1. Read the relevant spawn, capability, delivery, or recovery section of [`../../README.md`](../../README.md).
10
+ 2. From the direct parent, use `subagent_status` to capture the Child Agent's Launch Contract, Runtime Profile, dependencies, result, and Recent Activity.
11
+ 3. Classify the fault as launch resolution, adjacency, ordinary-tool ceiling, delivery state, or Registry recovery.
12
+ 4. For configuration changes, identify the effective layer, make one scoped edit, validate JSON, and reload Pi.
13
+ 5. Repeat the same spawn or coordination operation. Finish when it succeeds or one named launch, adjacency, capability, delivery, or recovery boundary is evidenced.
14
+
15
+ A wait timeout observes without cancelling. Tool arrays contain only ordinary tools. Cancellation preserves a Child Session; deletion removes it.
@@ -39,11 +39,10 @@ export function buildEligibleModelIds(input: {
39
39
  return [...new Set(source.map(({ provider, id }) => `${provider}/${id}`))];
40
40
  }
41
41
 
42
- /** Supplies inherited tools, the ancestor ceiling, and runtime availability for exact tool resolution. */
42
+ /** Supplies inherited tools and the ancestor ceiling for exact tool resolution. */
43
43
  export interface ToolResolutionContext {
44
44
  ordinaryTools: readonly string[];
45
45
  capabilityCeiling: readonly string[];
46
- availableTools: readonly string[];
47
46
  }
48
47
 
49
48
  /** Resolve an exact ordinary-tool contract and reject missing or over-ceiling capabilities. */
@@ -69,13 +68,9 @@ export function resolveOrdinaryToolSelection(
69
68
  `Minimal subagents ordinary tool selection: coordinator tools are injected separately and must not appear in tools: ${requestedCoordinatorTools.join(", ")}`,
70
69
  );
71
70
  }
72
- const available = new Set(context.availableTools);
71
+ // ponytail: availableTools and capabilityCeiling are identical at every production site,
72
+ // so availability and the ancestor ceiling are enforced by a single membership check.
73
73
  const ceiling = new Set(context.capabilityCeiling);
74
- const missing = uniqueRequested.filter((name) => !available.has(name));
75
- if (missing.length > 0) {
76
- throw new Error(`Minimal subagents tool resolution: unavailable tool: ${missing.join(", ")}`);
77
- }
78
-
79
74
  const exceeded = uniqueRequested.filter((name) => !ceiling.has(name));
80
75
  if (exceeded.length > 0) {
81
76
  throw new Error(`Minimal subagents capability ceiling exceeded: ${exceeded.join(", ")}`);
@@ -1,5 +1,11 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { SessionContextMode } from "./minimal-subagents-types.js";
2
+ import { contentText, type ImageContent, type TextContent } from "@earendil-works/pi-ai";
3
+ import { truncateTail } from "@earendil-works/pi-coding-agent";
4
+ import type { RecentAgentActivity, SessionContextMode } from "./minimal-subagents-types.js";
5
+
6
+ const RECENT_AGENT_ACTIVITY_LIMIT = 12;
7
+ const RECENT_AGENT_ACTIVITY_MAX_LINES = 20;
8
+ const RECENT_AGENT_ACTIVITY_MAX_BYTES = 2 * 1024;
3
9
 
4
10
  /** Clone committed caller messages and exclude only the currently streaming assistant message. */
5
11
  export function snapshotCommittedContext(
@@ -11,6 +17,71 @@ export function snapshotCommittedContext(
11
17
  return structuredClone(committed);
12
18
  }
13
19
 
20
+ function boundedRecentActivityContent(label: string, content: string): RecentAgentActivity {
21
+ const bounded = truncateTail(content, {
22
+ maxLines: RECENT_AGENT_ACTIVITY_MAX_LINES,
23
+ maxBytes: RECENT_AGENT_ACTIVITY_MAX_BYTES,
24
+ });
25
+ return { label, content: bounded.content, truncated: bounded.truncated };
26
+ }
27
+
28
+ function visibleMessageContent(content: string | readonly (TextContent | ImageContent)[]): string {
29
+ return contentText(content, "\n\n") || "(no text content)";
30
+ }
31
+
32
+ /** Build a bounded recent activity tail from message text, reasoning, and tool work. */
33
+ export function buildRecentAgentActivity(messages: readonly AgentMessage[]): RecentAgentActivity[] {
34
+ const activity: RecentAgentActivity[] = [];
35
+ for (const message of messages) {
36
+ if (message.role === "assistant") {
37
+ for (const content of message.content) {
38
+ if (content.type === "text" && content.text) {
39
+ activity.push(boundedRecentActivityContent("assistant message", content.text));
40
+ } else if (content.type === "thinking") {
41
+ activity.push(
42
+ boundedRecentActivityContent(
43
+ "reasoning",
44
+ content.thinking ||
45
+ (content.redacted ? "[redacted reasoning]" : "(no reasoning text)"),
46
+ ),
47
+ );
48
+ } else if (content.type === "toolCall") {
49
+ activity.push(
50
+ boundedRecentActivityContent(
51
+ `tool call ${content.name}`,
52
+ JSON.stringify(content.arguments, null, 2),
53
+ ),
54
+ );
55
+ }
56
+ }
57
+ } else if (message.role === "toolResult") {
58
+ activity.push(
59
+ boundedRecentActivityContent(
60
+ `tool result ${message.toolName}${message.isError ? " (error)" : ""}`,
61
+ visibleMessageContent(message.content),
62
+ ),
63
+ );
64
+ } else if (message.role === "user" || message.role === "custom") {
65
+ activity.push(
66
+ boundedRecentActivityContent(
67
+ `${message.role} message`,
68
+ visibleMessageContent(message.content),
69
+ ),
70
+ );
71
+ } else if (message.role === "branchSummary" || message.role === "compactionSummary") {
72
+ activity.push(boundedRecentActivityContent(`${message.role} message`, message.summary));
73
+ } else if (message.role === "bashExecution") {
74
+ activity.push(
75
+ boundedRecentActivityContent(
76
+ `${message.role} message`,
77
+ `$ ${message.command}\n${message.output || "(no output)"}`,
78
+ ),
79
+ );
80
+ }
81
+ }
82
+ return activity.slice(-RECENT_AGENT_ACTIVITY_LIMIT);
83
+ }
84
+
14
85
  /** Carries the selected caller messages and whether child preparation should compact them. */
15
86
  export interface ImportedSubagentContext {
16
87
  messages: AgentMessage[];
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { assembleImportedContext, contextContainsImages } from "./minimal-subagents-context.js";
2
+ import {
3
+ assembleImportedContext,
4
+ buildRecentAgentActivity,
5
+ contextContainsImages,
6
+ } from "./minimal-subagents-context.js";
3
7
  import {
4
8
  canAgentContractSpawn,
5
9
  DEFAULT_MAX_SUBAGENT_DEPTH,
@@ -29,6 +33,7 @@ import {
29
33
  type DeliveryLedgerTransition,
30
34
  } from "./minimal-subagents-delivery-ledger.js";
31
35
  import { addCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
36
+ import { unavailableAgent } from "./minimal-subagents-sessions.js";
32
37
  import { createRegistryEvent } from "./minimal-subagents-registry.js";
33
38
  import type {
34
39
  AgentDetail,
@@ -51,7 +56,6 @@ import type {
51
56
  SpawnParameters,
52
57
  SpawnResult,
53
58
  StatusResult,
54
- TurnId,
55
59
  TurnResult,
56
60
  WaitMessageResult,
57
61
  WaitResult,
@@ -203,7 +207,6 @@ export class MinimalSubagentsCoordinator {
203
207
  const ordinaryTools = resolveOrdinaryToolSelection(parameters.tools, {
204
208
  ordinaryTools: excludeCoordinatorTools(caller.ordinaryTools),
205
209
  capabilityCeiling: excludeCoordinatorTools(caller.capabilityCeiling),
206
- availableTools: excludeCoordinatorTools(caller.availableTools),
207
210
  });
208
211
  const committedMessages = structuredClone(caller.messages);
209
212
  const imported = assembleImportedContext(sessionContext, committedMessages);
@@ -296,7 +299,6 @@ export class MinimalSubagentsCoordinator {
296
299
  thinkingLevel: agent.launch_contract.thinking_level,
297
300
  ordinaryTools: [...agent.launch_contract.ordinary_tools],
298
301
  capabilityCeiling: [...agent.capability_ceiling],
299
- availableTools: [...agent.capability_ceiling],
300
302
  spawnEntryId,
301
303
  };
302
304
  }
@@ -354,7 +356,7 @@ export class MinimalSubagentsCoordinator {
354
356
  }
355
357
  }
356
358
 
357
- /** Wait for one exact turn and claim its message/result delivery from automatic fallback. */
359
+ /** Wait for one exact turn, returning detailed child status if the waiter times out. */
358
360
  wait(
359
361
  callerId: string,
360
362
  agentId: string,
@@ -405,13 +407,16 @@ export class MinimalSubagentsCoordinator {
405
407
  reject(error);
406
408
  };
407
409
  if (timeoutMs !== undefined) {
408
- waiter.timeout = setTimeout(
409
- () =>
410
- stopWaiting(
411
- new Error(`Minimal subagents wait timed out for ${agentId} after ${timeoutMs}ms`),
412
- ),
413
- timeoutMs,
414
- );
410
+ waiter.timeout = setTimeout(() => {
411
+ this.removeWaiter(key, waiter);
412
+ resolve({
413
+ event: "timeout",
414
+ agent_id: agentId,
415
+ turn_id: turnId,
416
+ timeout_ms: timeoutMs,
417
+ agent: this.buildAgentDetail(agent, false),
418
+ });
419
+ }, timeoutMs);
415
420
  }
416
421
  if (signal) {
417
422
  waiter.abortListener = () =>
@@ -492,7 +497,6 @@ export class MinimalSubagentsCoordinator {
492
497
  agent_id: agentId,
493
498
  recursive,
494
499
  deleted_agent_ids: [],
495
- tombstoned_agent_ids: [],
496
500
  trashed_session_files: [],
497
501
  failures: [],
498
502
  };
@@ -520,7 +524,6 @@ export class MinimalSubagentsCoordinator {
520
524
  this.pruneRecentMessageProjectionsForDeletedAgent(agent.agent_id);
521
525
  this.tombstones.add(agent.agent_id);
522
526
  result.deleted_agent_ids.push(agent.agent_id);
523
- result.tombstoned_agent_ids.push(agent.agent_id);
524
527
  this.dependencies.registry.append(
525
528
  createRegistryEvent(this.dependencies.registry.rootSessionId, "agent-deleted", {
526
529
  agent_ids: [agent.agent_id],
@@ -567,12 +570,8 @@ export class MinimalSubagentsCoordinator {
567
570
  this.runtimeInitializations.clear();
568
571
  this.pendingAgentIds.clear();
569
572
  this.tombstones.clear();
570
- this.deliveryLedger = createDeliveryLedger();
571
573
  this.waiters.clear();
572
- this.pendingParentMessages.clear();
573
574
  this.waitHandedDeliveryIds.clear();
574
- this.recipientQueues.clear();
575
- this.backgroundOperations.clear();
576
575
  this.automaticDeliveryKeys.clear();
577
576
  this.automaticCoordinationDeliveryIds.clear();
578
577
  this.deliveryLedger = createDeliveryLedger({
@@ -916,9 +915,8 @@ export class MinimalSubagentsCoordinator {
916
915
  return initialization;
917
916
  }
918
917
 
919
- private beginTurn(agent: PersistedAgent): TurnId {
920
- // SAFETY: The generated value embeds the canonical agent ID and a fresh turn UUID before branding.
921
- const turnId = `${agent.agent_id}:turn-${randomUUID()}` as TurnId;
918
+ private beginTurn(agent: PersistedAgent): string {
919
+ const turnId = `${agent.agent_id}:turn-${randomUUID()}`;
922
920
  const startedAt = this.now().toISOString();
923
921
  agent.active_turn_id = turnId;
924
922
  agent.active_turn_started_at = startedAt;
@@ -1027,11 +1025,6 @@ export class MinimalSubagentsCoordinator {
1027
1025
  this.settleDelivery(delivery);
1028
1026
  return;
1029
1027
  }
1030
- if (!this.acceptingOperations || this.shouldStopAutomaticTerminalDelivery(delivery)) return;
1031
- if (this.hasDeliveryEvidence(delivery)) {
1032
- this.settleDelivery(delivery);
1033
- return;
1034
- }
1035
1028
  const message: CoordinatorMessage = {
1036
1029
  customType: "minimal-subagents.result",
1037
1030
  content: result.output,
@@ -1478,11 +1471,6 @@ export class MinimalSubagentsCoordinator {
1478
1471
  elapsed_ms: elapsed ?? agent.latest_result?.elapsed_ms,
1479
1472
  latest_activity_at: agent.latest_activity_at ?? agent.created_at,
1480
1473
  task: agent.task,
1481
- latest_activity: agent.active_turn_id
1482
- ? "turn running"
1483
- : agent.latest_result
1484
- ? `turn ${agent.latest_result.status}`
1485
- : "created",
1486
1474
  child_count: directChildren.length,
1487
1475
  children,
1488
1476
  };
@@ -1490,7 +1478,8 @@ export class MinimalSubagentsCoordinator {
1490
1478
 
1491
1479
  private buildAgentDetail(agent: PersistedAgent, includeDescendants = true): AgentDetail {
1492
1480
  const summary = this.buildAgentSummary(agent, includeDescendants);
1493
- const runtimeUsage = this.runtimes.get(agent.agent_id)?.getUsage();
1481
+ const runtime = this.runtimes.get(agent.agent_id);
1482
+ const runtimeUsage = runtime?.getUsage();
1494
1483
  return {
1495
1484
  ...summary,
1496
1485
  session_file: agent.session_file,
@@ -1498,6 +1487,7 @@ export class MinimalSubagentsCoordinator {
1498
1487
  capability_ceiling: [...agent.capability_ceiling],
1499
1488
  spawn_entry_id: agent.spawn_entry_id,
1500
1489
  recent_messages: structuredClone(agent.recent_messages),
1490
+ recent_activity: buildRecentAgentActivity(runtime?.snapshotActivityMessages() ?? []),
1501
1491
  latest_result: agent.latest_result ? structuredClone(agent.latest_result) : undefined,
1502
1492
  missing_dependencies: [...agent.missing_dependencies],
1503
1493
  unavailable_reason: agent.unavailable_reason,
@@ -1621,19 +1611,7 @@ export class MinimalSubagentsCoordinator {
1621
1611
  }
1622
1612
 
1623
1613
  private createForkPlaceholder(agent: PersistedAgent, cloneError: string): PersistedAgent {
1624
- return {
1625
- ...structuredClone(agent),
1626
- session_file: undefined,
1627
- session_id: undefined,
1628
- session_leaf_id: undefined,
1629
- clone_error: cloneError,
1630
- active_turn_id: undefined,
1631
- active_turn_started_at: undefined,
1632
- latest_activity_at: this.now().toISOString(),
1633
- availability: "unavailable",
1634
- missing_dependencies: [cloneError],
1635
- unavailable_reason: cloneError,
1636
- };
1614
+ return unavailableAgent(agent, cloneError, this.now().toISOString());
1637
1615
  }
1638
1616
 
1639
1617
  private rejectAllWaiters(error: Error): void {
@@ -39,6 +39,7 @@ import {
39
39
  findDeliveryEvidence,
40
40
  PiAgentSessionFactory,
41
41
  type PiAgentSessionFactoryOptions,
42
+ unavailableAgent,
42
43
  } from "./minimal-subagents-sessions.js";
43
44
  import { shutdownMinimalSubagentsSession } from "./minimal-subagents-shutdown.js";
44
45
  import { createCoordinatorToolDefinitions } from "./minimal-subagents-tools.js";
@@ -53,6 +54,7 @@ import type {
53
54
  CoordinatorNotification,
54
55
  ForkSnapshot,
55
56
  PersistedAgent,
57
+ PersistedSessionIdentity,
56
58
  RegistrySnapshot,
57
59
  RootConversationEndpoint,
58
60
  } from "./minimal-subagents-types.js";
@@ -75,7 +77,6 @@ function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): Caller
75
77
  thinkingLevel: context.thinkingLevel ?? pi.getThinkingLevel(),
76
78
  ordinaryTools: activeTools,
77
79
  capabilityCeiling: availableTools,
78
- availableTools,
79
80
  spawnEntryId: context.sessionManager.getLeafId() ?? "root",
80
81
  };
81
82
  }
@@ -183,57 +184,84 @@ function orderForkAgentsParentFirst(agents: readonly PersistedAgent[]): Persiste
183
184
  }
184
185
 
185
186
  function unavailableForkAgent(agent: PersistedAgent, error: string): PersistedAgent {
186
- return {
187
- ...structuredClone(agent),
188
- session_file: undefined,
189
- session_id: undefined,
190
- session_leaf_id: undefined,
191
- clone_error: error,
192
- active_turn_id: undefined,
193
- active_turn_started_at: undefined,
194
- availability: "unavailable",
195
- missing_dependencies: [error],
196
- unavailable_reason: error,
197
- };
187
+ return unavailableAgent(agent, error);
198
188
  }
199
189
 
200
- async function cloneSelectedForkSessions(
201
- sessionFactory: AgentSessionFactory,
202
- snapshot: RegistrySnapshot,
203
- sourceRootSessionFile: string,
204
- sourceRootSessionId: string,
190
+ /** One per-agent fork rebind step returning the destination session identity. */
191
+ type ForkAgentRebind = (agent: PersistedAgent) => Promise<PersistedSessionIdentity>;
192
+
193
+ interface ForkRebindOptions {
194
+ /** Require an existing clone session before rebinding (ownership pass only). */
195
+ readonly requiresCloneSession: boolean;
196
+ /** Message for agents skipped because an ancestor's rebind failed. */
197
+ readonly skippedMessage: (agent: PersistedAgent, failedAncestor: string) => string;
198
+ /** Human label used in failure notifications. */
199
+ readonly notifyLabel: string;
200
+ }
201
+
202
+ /** Walk fork agents parent-first, rebind each session, and quarantine failed subtrees. */
203
+ async function rebindForkAgents(
204
+ snapshotAgents: readonly PersistedAgent[],
205
+ rebind: ForkAgentRebind,
206
+ options: ForkRebindOptions,
205
207
  context: ExtensionContext,
206
- ): Promise<ForkSnapshot> {
208
+ ): Promise<PersistedAgent[]> {
207
209
  const agents: PersistedAgent[] = [];
208
210
  const failedSubtrees = new Set<string>();
209
- for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
211
+ for (const agent of orderForkAgentsParentFirst(snapshotAgents)) {
210
212
  const failedAncestor = [...failedSubtrees].find(
211
213
  (agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
212
214
  );
213
- if (failedAncestor) {
214
- agents.push(unavailableForkAgent(agent, `Ancestor clone failed: ${failedAncestor}`));
215
+ const missingCloneSession =
216
+ options.requiresCloneSession && (!agent.session_file || !agent.session_id);
217
+ if (failedAncestor !== undefined || missingCloneSession) {
218
+ const message =
219
+ failedAncestor !== undefined
220
+ ? options.skippedMessage(agent, failedAncestor)
221
+ : (agent.clone_error ?? `Ancestor ownership failed: ${agent.agent_id}`);
222
+ agents.push(unavailableForkAgent(agent, message));
215
223
  continue;
216
224
  }
217
225
  try {
218
- const clone = await sessionFactory.cloneForkSourceSession(agent, sourceRootSessionId);
219
- if (!clone.sessionLeafId) {
226
+ const identity = await rebind(agent);
227
+ if (!identity.sessionLeafId) {
220
228
  throw new Error(
221
229
  `Minimal subagents fork recovery: no selected session leaf for ${agent.agent_id}`,
222
230
  );
223
231
  }
224
232
  agents.push({
225
233
  ...structuredClone(agent),
226
- session_file: clone.sessionFile,
227
- session_id: clone.sessionId,
228
- session_leaf_id: clone.sessionLeafId,
234
+ session_file: identity.sessionFile,
235
+ session_id: identity.sessionId,
236
+ session_leaf_id: identity.sessionLeafId,
229
237
  });
230
238
  } catch (error) {
231
239
  const message = error instanceof Error ? error.message : String(error);
232
240
  failedSubtrees.add(agent.agent_id);
233
241
  agents.push(unavailableForkAgent(agent, message));
234
- context.ui.notify(`Fork recovery clone failed for ${agent.agent_id}: ${message}`, "error");
242
+ context.ui.notify(`${options.notifyLabel} for ${agent.agent_id}: ${message}`, "error");
235
243
  }
236
244
  }
245
+ return agents;
246
+ }
247
+
248
+ async function cloneSelectedForkSessions(
249
+ sessionFactory: AgentSessionFactory,
250
+ snapshot: RegistrySnapshot,
251
+ sourceRootSessionFile: string,
252
+ sourceRootSessionId: string,
253
+ context: ExtensionContext,
254
+ ): Promise<ForkSnapshot> {
255
+ const agents = await rebindForkAgents(
256
+ snapshot.agents,
257
+ (agent) => sessionFactory.cloneForkSourceSession(agent, sourceRootSessionId),
258
+ {
259
+ requiresCloneSession: false,
260
+ skippedMessage: (_agent, failedAncestor) => `Ancestor clone failed: ${failedAncestor}`,
261
+ notifyLabel: "Fork recovery clone failed",
262
+ },
263
+ context,
264
+ );
237
265
  return {
238
266
  ...structuredClone(snapshot),
239
267
  source_root_session_file: sourceRootSessionFile,
@@ -247,44 +275,16 @@ async function bindForkSnapshotToDestination(
247
275
  snapshot: ForkSnapshot,
248
276
  context: ExtensionContext,
249
277
  ): Promise<ForkSnapshot> {
250
- const agents: PersistedAgent[] = [];
251
- const failedSubtrees = new Set<string>();
252
- for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
253
- const failedAncestor = [...failedSubtrees].find(
254
- (agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
255
- );
256
- if (failedAncestor || !agent.session_file || !agent.session_id) {
257
- agents.push(
258
- unavailableForkAgent(
259
- agent,
260
- agent.clone_error ?? `Ancestor ownership failed: ${failedAncestor ?? agent.agent_id}`,
261
- ),
262
- );
263
- continue;
264
- }
265
- try {
266
- const owned = await sessionFactory.adoptForkSessionOwnership(
267
- agent,
268
- snapshot.source_root_session_id,
269
- );
270
- if (!owned.sessionLeafId) {
271
- throw new Error(
272
- `Minimal subagents fork ownership: no selected session leaf for ${agent.agent_id}`,
273
- );
274
- }
275
- agents.push({
276
- ...structuredClone(agent),
277
- session_file: owned.sessionFile,
278
- session_id: owned.sessionId,
279
- session_leaf_id: owned.sessionLeafId,
280
- });
281
- } catch (error) {
282
- const message = error instanceof Error ? error.message : String(error);
283
- failedSubtrees.add(agent.agent_id);
284
- agents.push(unavailableForkAgent(agent, message));
285
- context.ui.notify(`Fork ownership failed for ${agent.agent_id}: ${message}`, "error");
286
- }
287
- }
278
+ const agents = await rebindForkAgents(
279
+ snapshot.agents,
280
+ (agent) => sessionFactory.adoptForkSessionOwnership(agent, snapshot.source_root_session_id),
281
+ {
282
+ requiresCloneSession: true,
283
+ skippedMessage: () => "Ancestor ownership failed",
284
+ notifyLabel: "Fork ownership failed",
285
+ },
286
+ context,
287
+ );
288
288
  return { ...structuredClone(snapshot), agents };
289
289
  }
290
290
 
@@ -1,6 +1,5 @@
1
- import { existsSync, realpathSync } from "node:fs";
2
- import { resolve } from "node:path";
3
1
  import type { ForkSnapshot } from "./minimal-subagents-types.js";
2
+ import { canonicalPath } from "./minimal-subagents-sessions.js";
4
3
 
5
4
  declare global {
6
5
  // eslint-disable-next-line no-var -- A process-global handoff must be visible to replacement extension instances.
@@ -12,11 +11,6 @@ function forkSnapshotStore(): Map<string, ForkSnapshot> {
12
11
  return globalThis.minimalSubagentsForkSnapshots;
13
12
  }
14
13
 
15
- function canonicalSessionFile(sessionFile: string): string {
16
- const absolutePath = resolve(sessionFile);
17
- return existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
18
- }
19
-
20
14
  /** Prove a process-loss fork destination was derived from the expected canonical source file. */
21
15
  export function isForkDestinationForSource(
22
16
  destinationHeader: { parentSession?: string } | null,
@@ -24,14 +18,13 @@ export function isForkDestinationForSource(
24
18
  ): boolean {
25
19
  return (
26
20
  destinationHeader?.parentSession !== undefined &&
27
- canonicalSessionFile(destinationHeader.parentSession) ===
28
- canonicalSessionFile(previousSessionFile)
21
+ canonicalPath(destinationHeader.parentSession) === canonicalPath(previousSessionFile)
29
22
  );
30
23
  }
31
24
 
32
25
  /** Retain a complete pre-fork hierarchy across Pi extension-instance replacement. */
33
26
  export function rememberForkSnapshot(snapshot: ForkSnapshot): void {
34
- const canonicalSourceFile = canonicalSessionFile(snapshot.source_root_session_file);
27
+ const canonicalSourceFile = canonicalPath(snapshot.source_root_session_file);
35
28
  const retained = structuredClone(snapshot);
36
29
  retained.source_root_session_file = canonicalSourceFile;
37
30
  forkSnapshotStore().set(canonicalSourceFile, retained);
@@ -39,7 +32,7 @@ export function rememberForkSnapshot(snapshot: ForkSnapshot): void {
39
32
 
40
33
  /** Consume the pre-fork hierarchy once when the destination root session starts. */
41
34
  export function takeForkSnapshot(previousSessionFile: string): ForkSnapshot | undefined {
42
- const key = canonicalSessionFile(previousSessionFile);
35
+ const key = canonicalPath(previousSessionFile);
43
36
  const snapshot = forkSnapshotStore().get(key);
44
37
  if (snapshot) forkSnapshotStore().delete(key);
45
38
  return snapshot ? structuredClone(snapshot) : undefined;
@@ -102,7 +102,6 @@ export const RegistryAgentWireSchema = Type.Object({
102
102
  missing_dependencies: Type.Array(NonEmptyStringSchema),
103
103
  unavailable_reason: Type.Optional(NonEmptyStringSchema),
104
104
  recent_messages: Type.Array(RegistryRecentMessageWireSchema),
105
- deleted: Type.Optional(Type.Boolean()),
106
105
  });
107
106
 
108
107
  /** Parses one terminal Delivery Ledger item without applying ownership semantics. */
@@ -399,7 +399,6 @@ function parseRegistryAgent(value: RegistryAgentWire, version: 1 | 2): Persisted
399
399
  agent.latest_result = cloneRegistryTurnResult(value.latest_result);
400
400
  }
401
401
  if (value.unavailable_reason !== undefined) agent.unavailable_reason = value.unavailable_reason;
402
- if (value.deleted !== undefined) agent.deleted = value.deleted;
403
402
  return agent;
404
403
  }
405
404
 
@@ -620,12 +619,6 @@ function validateRegistrySnapshot(
620
619
  "tombstone agent ID is not canonical",
621
620
  );
622
621
  }
623
- if (agents.some((agent) => agent.deleted === true)) {
624
- return invalidRegistrySnapshot(
625
- "invalid-agent-hierarchy",
626
- "live snapshot agents cannot be deleted",
627
- );
628
- }
629
622
  const agentIds = new Set(agents.map((agent) => agent.agent_id));
630
623
  if (tombstones.some((agentId) => agentIds.has(agentId))) {
631
624
  return invalidRegistrySnapshot(
@@ -1245,8 +1238,7 @@ export function replayRegistryEntries(
1245
1238
  if (
1246
1239
  event.agent.active_turn_id ||
1247
1240
  event.agent.latest_result ||
1248
- event.agent.recent_messages.length > 0 ||
1249
- event.agent.deleted
1241
+ event.agent.recent_messages.length > 0
1250
1242
  ) {
1251
1243
  reportDiagnostic(
1252
1244
  diagnostics,
@@ -5,15 +5,9 @@ import type {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { Type, type Static } from "typebox";
7
7
  import { Value } from "typebox/value";
8
+ import { COORDINATOR_TOOL_NAMES } from "./minimal-subagents-capabilities.js";
8
9
 
9
- /** Names of the six coordinator tools with custom transcript renderers. */
10
- export type CoordinatorToolName =
11
- | "subagent"
12
- | "agent_message"
13
- | "subagent_wait"
14
- | "subagent_status"
15
- | "subagent_cancel"
16
- | "subagent_delete";
10
+ export type CoordinatorToolName = (typeof COORDINATOR_TOOL_NAMES)[number];
17
11
 
18
12
  const RenderUsageSchema = Type.Object({
19
13
  input: Type.Number(),
@@ -55,6 +49,11 @@ const RenderRecentMessageSchema = Type.Object({
55
49
  source_agent_id: Type.Optional(Type.String()),
56
50
  content: Type.Optional(Type.String()),
57
51
  });
52
+ const RenderRecentActivitySchema = Type.Object({
53
+ label: Type.String(),
54
+ content: Type.String(),
55
+ truncated: Type.Boolean(),
56
+ });
58
57
 
59
58
  /** Structurally parsed hierarchy status used only by transcript presentation. */
60
59
  export const RenderStatusAgentSchema = Type.Object({
@@ -80,6 +79,7 @@ export const RenderStatusAgentSchema = Type.Object({
80
79
  capability_ceiling: Type.Optional(Type.Array(Type.String())),
81
80
  spawn_entry_id: Type.Optional(Type.String()),
82
81
  recent_messages: Type.Optional(Type.Array(RenderRecentMessageSchema)),
82
+ recent_activity: Type.Optional(Type.Array(RenderRecentActivitySchema)),
83
83
  latest_result: Type.Optional(RenderTurnResultSchema),
84
84
  missing_dependencies: Type.Optional(Type.Array(Type.String())),
85
85
  unavailable_reason: Type.Optional(Type.String()),
@@ -148,6 +148,13 @@ const WaitTurnDetailsSchema = Type.Object({
148
148
  usage: Type.Optional(RenderUsageSchema),
149
149
  messages: Type.Optional(Type.Array(WaitMessageDetailsSchema)),
150
150
  });
151
+ const WaitTimeoutDetailsSchema = Type.Object({
152
+ event: Type.Literal("timeout"),
153
+ agent_id: Type.String(),
154
+ turn_id: Type.String(),
155
+ timeout_ms: Type.Number(),
156
+ agent: RenderStatusAgentSchema,
157
+ });
151
158
  const StatusDetailsSchema = Type.Union([
152
159
  Type.Object({
153
160
  parent_id: Type.Optional(Type.String()),
@@ -165,7 +172,6 @@ const DeleteDetailsSchema = Type.Object({
165
172
  agent_id: Type.String(),
166
173
  recursive: Type.Boolean(),
167
174
  deleted_agent_ids: Type.Array(Type.String()),
168
- tombstoned_agent_ids: Type.Array(Type.String()),
169
175
  trashed_session_files: Type.Array(Type.String()),
170
176
  failures: Type.Array(
171
177
  Type.Object({
@@ -179,7 +185,11 @@ const MessageRenderDetailsSchema = Type.Union([
179
185
  CurrentMessageDetailsSchema,
180
186
  LegacyMessageDetailsSchema,
181
187
  ]);
182
- const WaitRenderDetailsSchema = Type.Union([WaitMessageDetailsSchema, WaitTurnDetailsSchema]);
188
+ const WaitRenderDetailsSchema = Type.Union([
189
+ WaitMessageDetailsSchema,
190
+ WaitTurnDetailsSchema,
191
+ WaitTimeoutDetailsSchema,
192
+ ]);
183
193
 
184
194
  export type SpawnCallArguments = Static<typeof SpawnCallArgumentsSchema>;
185
195
  export type MessageCallArguments = Static<typeof MessageCallArgumentsSchema>;
@@ -62,7 +62,8 @@ type SubagentPresentationStatus =
62
62
  | "delivered"
63
63
  | "delivered-via-wait"
64
64
  | "queued"
65
- | "message";
65
+ | "message"
66
+ | "timed out";
66
67
 
67
68
  type SubagentStatusPresentation = { readonly symbol: string; readonly color: ThemeColor };
68
69
 
@@ -79,6 +80,7 @@ const SUBAGENT_STATUS_PRESENTATION = {
79
80
  "delivered-via-wait": { symbol: "→", color: "accent" },
80
81
  queued: { symbol: "↗", color: "accent" },
81
82
  message: { symbol: "→", color: "accent" },
83
+ "timed out": { symbol: "!", color: "warning" },
82
84
  } satisfies { readonly [Status in SubagentPresentationStatus]: SubagentStatusPresentation };
83
85
 
84
86
  function coordinatorMessageText(content: RenderableCoordinatorMessage["content"]): string {
@@ -96,11 +98,21 @@ function toolResultText(result: AgentToolResult<unknown>): string {
96
98
  return text?.type === "text" ? text.text : "";
97
99
  }
98
100
 
101
+ /** Shared unavailable → running → latest-turn → idle status ladder for one subagent. */
102
+ export function subagentStatusLadder(agent: {
103
+ readonly availability?: string;
104
+ readonly state?: string;
105
+ readonly latest_turn?: { readonly status?: string };
106
+ }): string {
107
+ if (agent.availability === "unavailable") return "unavailable";
108
+ if (agent.state === "running") return "running";
109
+ return agent.latest_turn?.status ?? "idle";
110
+ }
111
+
99
112
  function subagentStatusPresentation(status: string): SubagentStatusPresentation {
100
- for (const [knownStatus, presentation] of Object.entries(SUBAGENT_STATUS_PRESENTATION)) {
101
- if (knownStatus === status) return presentation;
102
- }
103
- return SUBAGENT_STATUS_PRESENTATION.idle;
113
+ // SAFETY: unknown statuses fall back to the idle presentation below.
114
+ const known = status as keyof typeof SUBAGENT_STATUS_PRESENTATION;
115
+ return SUBAGENT_STATUS_PRESENTATION[known] ?? SUBAGENT_STATUS_PRESENTATION.idle;
104
116
  }
105
117
 
106
118
  /** Render the shared semantic symbol and color for one subagent status. */
@@ -338,10 +350,19 @@ function renderWaitResult(
338
350
  ? "waiting"
339
351
  : details.event === "message"
340
352
  ? "message"
341
- : details.status;
342
- const duration = formatSubagentDuration(details.elapsed_ms);
343
- const tokens = formatSubagentTokenCount(details.usage?.totalTokens);
344
- const drainedMessageCount = details.event === "message" ? 0 : (details.messages?.length ?? 0);
353
+ : details.event === "timeout"
354
+ ? "timed out"
355
+ : details.status;
356
+ const duration = formatSubagentDuration(
357
+ details.event === "timeout" ? details.timeout_ms : details.elapsed_ms,
358
+ );
359
+ const tokens = formatSubagentTokenCount(
360
+ details.event === "timeout" ? undefined : details.usage?.totalTokens,
361
+ );
362
+ const drainedMessageCount =
363
+ details.event === "message" || details.event === "timeout"
364
+ ? 0
365
+ : (details.messages?.length ?? 0);
345
366
  const metrics = [
346
367
  duration,
347
368
  tokens ? `${tokens} tokens` : undefined,
@@ -359,6 +380,15 @@ function renderWaitResult(
359
380
  container.addChild(renderLabelValue(theme, "Message ID", details.message_id));
360
381
  return container;
361
382
  }
383
+ if (details.event === "timeout") {
384
+ appendComponentSection(
385
+ container,
386
+ theme,
387
+ "Child status",
388
+ renderDetailedStatusAgent(details.agent, options, theme),
389
+ );
390
+ return container;
391
+ }
362
392
  if (details.messages && details.messages.length > 0) {
363
393
  appendTextSection(
364
394
  container,
@@ -402,9 +432,7 @@ function countDirectStatusAgents(agents: readonly RenderStatusAgent[]): DirectSt
402
432
  }
403
433
 
404
434
  function statusAgentPresentation(agent: RenderStatusAgent): string {
405
- if (agent.availability === "unavailable") return "unavailable";
406
- if (agent.state === "running") return "running";
407
- return agent.latest_turn?.status ?? "idle";
435
+ return subagentStatusLadder(agent);
408
436
  }
409
437
 
410
438
  function renderDirectStatusRows(
@@ -484,6 +512,20 @@ function renderDetailedStatusAgent(
484
512
  if (agent.unavailable_reason) {
485
513
  appendTextSection(container, theme, "Unavailable reason", agent.unavailable_reason);
486
514
  }
515
+ const recentActivity = agent.recent_activity ?? [];
516
+ if (recentActivity.length > 0) {
517
+ appendTextSection(
518
+ container,
519
+ theme,
520
+ "Recent activity",
521
+ recentActivity
522
+ .map(
523
+ (activity) =>
524
+ `${activity.label}${activity.truncated ? " (truncated)" : ""}\n${activity.content}`,
525
+ )
526
+ .join("\n\n"),
527
+ );
528
+ }
487
529
  const recentMessages = agent.recent_messages ?? [];
488
530
  if (recentMessages.length > 0) {
489
531
  appendTextSection(
@@ -577,7 +619,7 @@ function renderDeleteResult(
577
619
  const status = details.failures.length > 0 ? "failed" : "completed";
578
620
  const metrics = [
579
621
  `${details.deleted_agent_ids.length} agents deleted`,
580
- `${details.tombstoned_agent_ids.length} tombstoned`,
622
+ `${details.deleted_agent_ids.length} tombstones`,
581
623
  details.failures.length > 0 ? `${details.failures.length} failed` : undefined,
582
624
  ].filter((metric): metric is string => metric !== undefined);
583
625
  const summary = renderSubagentSummary(theme, status, details.agent_id, metrics);
@@ -594,12 +636,6 @@ function renderDeleteResult(
594
636
  "Deleted agents",
595
637
  details.deleted_agent_ids.join("\n") || "(none)",
596
638
  );
597
- appendTextSection(
598
- container,
599
- theme,
600
- "Tombstones",
601
- details.tombstoned_agent_ids.join("\n") || "(none)",
602
- );
603
639
  appendTextSection(
604
640
  container,
605
641
  theme,
@@ -131,11 +131,35 @@ export function buildDepthBoundSubagentPrompt(
131
131
  });
132
132
  }
133
133
 
134
- function canonicalPath(path: string): string {
134
+ /** Canonicalize one path, resolving symlinks only when the target exists. */
135
+ export function canonicalPath(path: string): string {
135
136
  const absolutePath = resolve(path);
136
137
  return existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
137
138
  }
138
139
 
140
+ /** Build the shared unavailable-agent projection used by fork recovery and ownership binding. */
141
+ export function unavailableAgent(
142
+ agent: PersistedAgent,
143
+ error: string,
144
+ latestActivityAt?: string,
145
+ ): PersistedAgent {
146
+ // SAFETY: the spread preserves every required PersistedAgent field; only optional session fields are cleared.
147
+ const unavailable = {
148
+ ...structuredClone(agent),
149
+ session_file: undefined,
150
+ session_id: undefined,
151
+ session_leaf_id: undefined,
152
+ clone_error: error,
153
+ active_turn_id: undefined,
154
+ active_turn_started_at: undefined,
155
+ availability: "unavailable",
156
+ missing_dependencies: [error],
157
+ unavailable_reason: error,
158
+ } as PersistedAgent;
159
+ if (latestActivityAt !== undefined) unavailable.latest_activity_at = latestActivityAt;
160
+ return unavailable;
161
+ }
162
+
139
163
  function appendImportedMessage(sessionManager: SessionManager, message: AgentMessage): void {
140
164
  if (message.role === "compactionSummary") {
141
165
  sessionManager.appendCustomMessageEntry(
@@ -439,54 +463,40 @@ function assistantText(message: AgentMessage | undefined): string {
439
463
  .join("\n");
440
464
  }
441
465
 
442
- /** Collects finalized turn messages without relying on mutable post-compaction session state. */
443
- export class ChildTurnOutcomeCollector {
444
- private readonly messages: AgentMessage[] = [];
445
- private readonly unsubscribe: () => void;
446
-
447
- constructor(session: Pick<AgentSession, "subscribe">) {
448
- this.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
449
- if (event.type === "message_end") this.messages.push(event.message);
450
- });
466
+ /** Collect finalized turn messages and reduce them to one runtime outcome. */
467
+ function collectChildTurnOutcome(
468
+ messages: readonly AgentMessage[],
469
+ aborted: boolean,
470
+ ): RuntimeTurnOutcome {
471
+ const finalAssistant = [...messages].reverse().find((message) => message.role === "assistant");
472
+ if (!finalAssistant || finalAssistant.role !== "assistant") {
473
+ return {
474
+ status: aborted ? "cancelled" : "failed",
475
+ output: "",
476
+ error: "No terminal assistant response",
477
+ };
451
478
  }
452
-
453
- dispose(): void {
454
- this.unsubscribe();
479
+ if (finalAssistant.stopReason === "aborted") {
480
+ return {
481
+ status: "cancelled",
482
+ output: assistantText(finalAssistant),
483
+ error: finalAssistant.errorMessage,
484
+ usage: sumUsage(messages),
485
+ };
455
486
  }
456
-
457
- toOutcome(aborted: boolean): RuntimeTurnOutcome {
458
- const finalAssistant = [...this.messages]
459
- .reverse()
460
- .find((message) => message.role === "assistant");
461
- if (!finalAssistant || finalAssistant.role !== "assistant") {
462
- return {
463
- status: aborted ? "cancelled" : "failed",
464
- output: "",
465
- error: "No terminal assistant response",
466
- };
467
- }
468
- if (finalAssistant.stopReason === "aborted") {
469
- return {
470
- status: "cancelled",
471
- output: assistantText(finalAssistant),
472
- error: finalAssistant.errorMessage,
473
- usage: sumUsage(this.messages),
474
- };
475
- }
476
- if (finalAssistant.stopReason === "error") {
477
- return {
478
- status: "failed",
479
- output: assistantText(finalAssistant),
480
- error: finalAssistant.errorMessage ?? "Provider request failed",
481
- usage: sumUsage(this.messages),
482
- };
483
- }
487
+ if (finalAssistant.stopReason === "error") {
484
488
  return {
485
- status: "completed",
489
+ status: "failed",
486
490
  output: assistantText(finalAssistant),
487
- usage: sumUsage(this.messages),
491
+ error: finalAssistant.errorMessage ?? "Provider request failed",
492
+ usage: sumUsage(messages),
488
493
  };
489
494
  }
495
+ return {
496
+ status: "completed",
497
+ output: assistantText(finalAssistant),
498
+ usage: sumUsage(messages),
499
+ };
490
500
  }
491
501
 
492
502
  /** Run one child operation while retaining its finalized outcome across compaction. */
@@ -495,10 +505,13 @@ export async function captureChildTurnOutcome(
495
505
  operation: () => Promise<void>,
496
506
  isAborted: () => boolean,
497
507
  ): Promise<RuntimeTurnOutcome> {
498
- const collector = new ChildTurnOutcomeCollector(session);
508
+ const messages: AgentMessage[] = [];
509
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
510
+ if (event.type === "message_end") messages.push(event.message);
511
+ });
499
512
  try {
500
513
  await operation();
501
- return collector.toOutcome(isAborted());
514
+ return collectChildTurnOutcome(messages, isAborted());
502
515
  } catch (error) {
503
516
  return {
504
517
  status: isAborted() ? "cancelled" : "failed",
@@ -506,7 +519,7 @@ export async function captureChildTurnOutcome(
506
519
  error: error instanceof Error ? error.message : String(error),
507
520
  };
508
521
  } finally {
509
- collector.dispose();
522
+ unsubscribe();
510
523
  }
511
524
  }
512
525
 
@@ -600,6 +613,11 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
600
613
  return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
601
614
  }
602
615
 
616
+ snapshotActivityMessages(): AgentMessage[] {
617
+ const streamingMessage = this.session.state.streamingMessage;
618
+ return [...this.session.messages, ...(streamingMessage ? [streamingMessage] : [])];
619
+ }
620
+
603
621
  hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
604
622
  return findDeliveryEvidence(
605
623
  this.session.sessionManager.getBranch(),
@@ -202,7 +202,7 @@ export function createCoordinatorToolDefinitions(
202
202
  name: "subagent_wait",
203
203
  label: "Subagent Wait",
204
204
  description:
205
- "Wait for one direct child's oldest observable turn, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. An already settled turn returns event=turn once with queued messages in messages. Timeout never cancels the child.",
205
+ "Wait for one direct child's oldest observable turn, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. An already settled turn returns event=turn once with queued messages in messages. Timeout returns event=timeout with detailed child status and never cancels the child.",
206
206
  promptSnippet: "Wait for one direct child's exact turn",
207
207
  parameters: options.schemas.subagent_wait,
208
208
  async execute(_toolCallId, parameters, signal, onUpdate) {
@@ -248,7 +248,7 @@ export function createCoordinatorToolDefinitions(
248
248
  name: "subagent_status",
249
249
  label: "Subagent Status",
250
250
  description:
251
- "List direct children when agent_id is omitted, or inspect one direct child's launch contract, result, usage, and dependencies.",
251
+ "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.",
252
252
  promptSnippet: "Inspect direct child state",
253
253
  parameters: options.schemas.subagent_status,
254
254
  async execute(_toolCallId, parameters) {
@@ -1,9 +1,6 @@
1
1
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Usage } from "@earendil-works/pi-ai";
3
3
 
4
- /** Stable identity for one prompt and its complete assistant/tool loop. */
5
- export type TurnId = string & { readonly __turnId: unique symbol };
6
-
7
4
  /** Controls how much committed caller conversation enters a new child session. */
8
5
  export type SessionContextMode = "inherit" | "compact" | "omit";
9
6
  /** Controls whether child resource discovery includes project instructions, skills, and prompts. */
@@ -81,8 +78,19 @@ export interface WaitTurnResult extends TurnResult {
81
78
  messages?: WaitMessageResult[];
82
79
  }
83
80
 
84
- /** Reports one terminal child turn returned by subagent_wait. */
85
- export type WaitResult = WaitMessageResult | WaitTurnResult;
81
+ /** Reports an observational wait timeout with the current detailed child status. */
82
+ export interface WaitTimeoutResult {
83
+ event: "timeout";
84
+ agent_id: string;
85
+ turn_id: string;
86
+ /** Requested timeout duration that expired. */
87
+ timeout_ms: number;
88
+ /** Detailed child status captured when the timeout callback won. */
89
+ agent: AgentDetail;
90
+ }
91
+
92
+ /** Reports one message, terminal turn, or timeout returned by subagent_wait. */
93
+ export type WaitResult = WaitMessageResult | WaitTurnResult | WaitTimeoutResult;
86
94
 
87
95
  /** Provides bounded hierarchy, usage, and best-known Runtime Profile data for one persistent agent. */
88
96
  export interface AgentSummary extends RuntimeProfile {
@@ -94,7 +102,6 @@ export interface AgentSummary extends RuntimeProfile {
94
102
  latest_turn?: Pick<TurnResult, "turn_id" | "status">;
95
103
  tools: string[];
96
104
  elapsed_ms?: number;
97
- latest_activity?: string;
98
105
  latest_activity_at?: string;
99
106
  task?: string;
100
107
  child_count: number;
@@ -108,13 +115,22 @@ export interface RecentAgentMessage {
108
115
  content: string;
109
116
  }
110
117
 
111
- /** Extends summary status with launch, dependency, and recent-message diagnostics. */
118
+ /** Reports one labeled, bounded child activity item without image data. */
119
+ export interface RecentAgentActivity {
120
+ label: string;
121
+ content: string;
122
+ truncated: boolean;
123
+ }
124
+
125
+ /** Extends summary status with launch, dependency, recent-message, and recent-work diagnostics. */
112
126
  export interface AgentDetail extends AgentSummary {
113
127
  session_file?: string;
114
128
  launch_contract: LaunchContract;
115
129
  capability_ceiling: string[];
116
130
  spawn_entry_id: string;
117
131
  recent_messages: RecentAgentMessage[];
132
+ /** The 12 most recent work items, each capped at 20 lines and 2 KiB. */
133
+ recent_activity: RecentAgentActivity[];
118
134
  latest_result?: TurnResult;
119
135
  missing_dependencies: string[];
120
136
  unavailable_reason?: string;
@@ -142,7 +158,6 @@ export interface DeleteResult {
142
158
  agent_id: string;
143
159
  recursive: boolean;
144
160
  deleted_agent_ids: string[];
145
- tombstoned_agent_ids: string[];
146
161
  trashed_session_files: string[];
147
162
  failures: Array<{ agent_id: string; error: string }>;
148
163
  }
@@ -163,7 +178,6 @@ export interface CallerSnapshot {
163
178
  thinkingLevel: ThinkingLevel;
164
179
  ordinaryTools: string[];
165
180
  capabilityCeiling: string[];
166
- availableTools: string[];
167
181
  spawnEntryId: string;
168
182
  }
169
183
 
@@ -209,7 +223,10 @@ export interface ChildAgentRuntime {
209
223
  dispose(): void;
210
224
  /** Return the live Runtime Profile, or undefined when the SDK session has no model. */
211
225
  getRuntimeProfile(): RuntimeProfile | undefined;
226
+ /** Clone committed child transcript messages while excluding the streaming assistant tail. */
212
227
  snapshotCommittedMessages(): AgentMessage[];
228
+ /** Clone child transcript messages including the current streaming assistant tail. */
229
+ snapshotActivityMessages(): AgentMessage[];
213
230
  hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean;
214
231
  getUsage(): Usage | undefined;
215
232
  }
@@ -276,7 +293,6 @@ export interface PersistedAgent {
276
293
  missing_dependencies: string[];
277
294
  unavailable_reason?: string;
278
295
  recent_messages: RecentAgentMessage[];
279
- deleted?: boolean;
280
296
  }
281
297
 
282
298
  /** Records which conversation path owns one successful terminal result. */
@@ -15,6 +15,7 @@ import {
15
15
  formatSubagentDuration,
16
16
  renderSubagentStatusLabel,
17
17
  renderSubagentStatusSymbol,
18
+ subagentStatusLadder,
18
19
  } from "./minimal-subagents-rendering.js";
19
20
  import type {
20
21
  AgentSummary,
@@ -65,9 +66,9 @@ function flattenAgentHierarchy(agents: readonly AgentSummary[]): FlattenedAgentS
65
66
  }
66
67
 
67
68
  function agentTerminalStatus(agent: AgentSummary): TurnStatus | "idle" | "unavailable" {
68
- if (agent.availability === "unavailable") return "unavailable";
69
- if (agent.state === "running") return "running";
70
- return agent.latest_turn?.status ?? "idle";
69
+ const status = subagentStatusLadder(agent);
70
+ // SAFETY: the shared ladder emits exactly the unavailable/running/TurnStatus/idle vocabulary.
71
+ return status as TurnStatus | "idle" | "unavailable";
71
72
  }
72
73
 
73
74
  function terminalTimestamp(agent: AgentSummary): number {