@ian-pascoe/pi-minimal-subagents 0.2.2 → 0.3.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.
@@ -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
  }
@@ -99,6 +100,7 @@ function createRootConversationEndpoint(
99
100
  },
100
101
  );
101
102
  },
103
+ isIdle: () => context.isIdle(),
102
104
  hasDeliveryEvidence: (sourceAgentId, sourceTurnId, deliveryId) =>
103
105
  findDeliveryEvidence(
104
106
  context.sessionManager.getBranch(),
@@ -182,57 +184,84 @@ function orderForkAgentsParentFirst(agents: readonly PersistedAgent[]): Persiste
182
184
  }
183
185
 
184
186
  function unavailableForkAgent(agent: PersistedAgent, error: string): PersistedAgent {
185
- return {
186
- ...structuredClone(agent),
187
- session_file: undefined,
188
- session_id: undefined,
189
- session_leaf_id: undefined,
190
- clone_error: error,
191
- active_turn_id: undefined,
192
- active_turn_started_at: undefined,
193
- availability: "unavailable",
194
- missing_dependencies: [error],
195
- unavailable_reason: error,
196
- };
187
+ return unavailableAgent(agent, error);
197
188
  }
198
189
 
199
- async function cloneSelectedForkSessions(
200
- sessionFactory: AgentSessionFactory,
201
- snapshot: RegistrySnapshot,
202
- sourceRootSessionFile: string,
203
- 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,
204
207
  context: ExtensionContext,
205
- ): Promise<ForkSnapshot> {
208
+ ): Promise<PersistedAgent[]> {
206
209
  const agents: PersistedAgent[] = [];
207
210
  const failedSubtrees = new Set<string>();
208
- for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
211
+ for (const agent of orderForkAgentsParentFirst(snapshotAgents)) {
209
212
  const failedAncestor = [...failedSubtrees].find(
210
213
  (agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
211
214
  );
212
- if (failedAncestor) {
213
- 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));
214
223
  continue;
215
224
  }
216
225
  try {
217
- const clone = await sessionFactory.cloneForkSourceSession(agent, sourceRootSessionId);
218
- if (!clone.sessionLeafId) {
226
+ const identity = await rebind(agent);
227
+ if (!identity.sessionLeafId) {
219
228
  throw new Error(
220
229
  `Minimal subagents fork recovery: no selected session leaf for ${agent.agent_id}`,
221
230
  );
222
231
  }
223
232
  agents.push({
224
233
  ...structuredClone(agent),
225
- session_file: clone.sessionFile,
226
- session_id: clone.sessionId,
227
- session_leaf_id: clone.sessionLeafId,
234
+ session_file: identity.sessionFile,
235
+ session_id: identity.sessionId,
236
+ session_leaf_id: identity.sessionLeafId,
228
237
  });
229
238
  } catch (error) {
230
239
  const message = error instanceof Error ? error.message : String(error);
231
240
  failedSubtrees.add(agent.agent_id);
232
241
  agents.push(unavailableForkAgent(agent, message));
233
- 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");
234
243
  }
235
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
+ );
236
265
  return {
237
266
  ...structuredClone(snapshot),
238
267
  source_root_session_file: sourceRootSessionFile,
@@ -246,44 +275,16 @@ async function bindForkSnapshotToDestination(
246
275
  snapshot: ForkSnapshot,
247
276
  context: ExtensionContext,
248
277
  ): Promise<ForkSnapshot> {
249
- const agents: PersistedAgent[] = [];
250
- const failedSubtrees = new Set<string>();
251
- for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
252
- const failedAncestor = [...failedSubtrees].find(
253
- (agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
254
- );
255
- if (failedAncestor || !agent.session_file || !agent.session_id) {
256
- agents.push(
257
- unavailableForkAgent(
258
- agent,
259
- agent.clone_error ?? `Ancestor ownership failed: ${failedAncestor ?? agent.agent_id}`,
260
- ),
261
- );
262
- continue;
263
- }
264
- try {
265
- const owned = await sessionFactory.adoptForkSessionOwnership(
266
- agent,
267
- snapshot.source_root_session_id,
268
- );
269
- if (!owned.sessionLeafId) {
270
- throw new Error(
271
- `Minimal subagents fork ownership: no selected session leaf for ${agent.agent_id}`,
272
- );
273
- }
274
- agents.push({
275
- ...structuredClone(agent),
276
- session_file: owned.sessionFile,
277
- session_id: owned.sessionId,
278
- session_leaf_id: owned.sessionLeafId,
279
- });
280
- } catch (error) {
281
- const message = error instanceof Error ? error.message : String(error);
282
- failedSubtrees.add(agent.agent_id);
283
- agents.push(unavailableForkAgent(agent, message));
284
- context.ui.notify(`Fork ownership failed for ${agent.agent_id}: ${message}`, "error");
285
- }
286
- }
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
+ );
287
288
  return { ...structuredClone(snapshot), agents };
288
289
  }
289
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
 
@@ -520,6 +533,8 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
520
533
  private readonly modelById: ReadonlyMap<string, Model<any>>,
521
534
  onSessionActivity?: () => void,
522
535
  ) {
536
+ // Keep this child-only; AgentSession.setSteeringMode would overwrite the user's global setting.
537
+ session.agent.steeringMode = "all";
523
538
  this.unsubscribe = session.subscribe((event) => {
524
539
  if (event.type !== "entry_appended") return;
525
540
  if (
@@ -598,6 +613,11 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
598
613
  return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
599
614
  }
600
615
 
616
+ snapshotActivityMessages(): AgentMessage[] {
617
+ const streamingMessage = this.session.state.streamingMessage;
618
+ return [...this.session.messages, ...(streamingMessage ? [streamingMessage] : [])];
619
+ }
620
+
601
621
  hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
602
622
  return findDeliveryEvidence(
603
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) {