@ian-pascoe/pi-minimal-subagents 0.1.0 → 0.2.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.
@@ -5,14 +5,25 @@ import {
5
5
  getAgentDir,
6
6
  SessionManager,
7
7
  SettingsManager,
8
+ type AgentSettledEvent,
8
9
  type ExtensionAPI,
9
10
  type ExtensionContext,
11
+ type ExtensionFactory,
12
+ type MessageEndEvent,
13
+ type SessionBeforeForkEvent,
10
14
  type SessionEntry,
15
+ type SessionShutdownEvent,
16
+ type SessionStartEvent,
17
+ type SessionTreeEvent,
11
18
  } from "@earendil-works/pi-coding-agent";
12
19
  import { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
13
20
  import { resolveMinimalSubagentsSettings } from "./minimal-subagents-config.js";
14
21
  import { snapshotCommittedContext } from "./minimal-subagents-context.js";
15
- import { rememberForkSnapshot, takeForkSnapshot } from "./minimal-subagents-fork-lifecycle.js";
22
+ import {
23
+ isForkDestinationForSource,
24
+ rememberForkSnapshot,
25
+ takeForkSnapshot,
26
+ } from "./minimal-subagents-fork-lifecycle.js";
16
27
  import {
17
28
  buildEligibleModelIds,
18
29
  COORDINATOR_TOOL_NAMES,
@@ -22,9 +33,14 @@ import {
22
33
  CHILD_IDENTITY_ENTRY_TYPE,
23
34
  REGISTRY_ENTRY_TYPE,
24
35
  replayRegistryEntries,
36
+ type RegistryReplayDiagnostic,
25
37
  } from "./minimal-subagents-registry.js";
26
38
  import { createCoordinatorToolSchemas } from "./minimal-subagents-tool-schemas.js";
27
- import { findDeliveryEvidence, PiAgentSessionFactory } from "./minimal-subagents-sessions.js";
39
+ import {
40
+ findDeliveryEvidence,
41
+ PiAgentSessionFactory,
42
+ type PiAgentSessionFactoryOptions,
43
+ } from "./minimal-subagents-sessions.js";
28
44
  import { shutdownMinimalSubagentsSession } from "./minimal-subagents-shutdown.js";
29
45
  import { createCoordinatorToolDefinitions } from "./minimal-subagents-tools.js";
30
46
  import {
@@ -33,9 +49,11 @@ import {
33
49
  } from "./minimal-subagents-rendering.js";
34
50
  import { MinimalSubagentsUiController } from "./minimal-subagents-ui.js";
35
51
  import type {
52
+ AgentSessionFactory,
36
53
  CallerSnapshot,
37
54
  CoordinatorNotification,
38
55
  ForkSnapshot,
56
+ PersistedAgent,
39
57
  RegistrySnapshot,
40
58
  RootConversationEndpoint,
41
59
  } from "./minimal-subagents-types.js";
@@ -43,7 +61,7 @@ import type {
43
61
  const EXTENSION_ENTRYPOINT = fileURLToPath(new URL("./index.ts", import.meta.url));
44
62
 
45
63
  function currentConversationMessages(context: ExtensionContext): AgentMessage[] {
46
- const entries = context.sessionManager.getEntries() as SessionEntry[];
64
+ const entries = context.sessionManager.getEntries();
47
65
  const messages = buildSessionContext(entries, context.sessionManager.getLeafId()).messages;
48
66
  return snapshotCommittedContext(messages, !context.isIdle());
49
67
  }
@@ -68,7 +86,7 @@ function createRootConversationEndpoint(
68
86
  context: ExtensionContext,
69
87
  ): RootConversationEndpoint {
70
88
  return {
71
- async steerCoordinatorMessage(message) {
89
+ async queueCoordinatorMessage(message) {
72
90
  pi.sendMessage(
73
91
  {
74
92
  customType: message.customType,
@@ -82,8 +100,14 @@ function createRootConversationEndpoint(
82
100
  },
83
101
  );
84
102
  },
85
- hasDeliveryEvidence: (sourceAgentId, sourceTurnId) =>
86
- findDeliveryEvidence(context.sessionManager.getEntries(), sourceAgentId, sourceTurnId),
103
+ hasDeliveryEvidence: (sourceAgentId, sourceTurnId, deliveryId) =>
104
+ findDeliveryEvidence(
105
+ context.sessionManager.getBranch(),
106
+ sourceAgentId,
107
+ sourceTurnId,
108
+ deliveryId,
109
+ ),
110
+ isIdle: () => context.isIdle(),
87
111
  };
88
112
  }
89
113
 
@@ -111,10 +135,158 @@ function hasHistoricalChildIdentity(entries: readonly SessionEntry[]): boolean {
111
135
  );
112
136
  }
113
137
 
114
- function replayPreviousRoot(previousSessionFile: string): RegistrySnapshot {
115
- const previousSession = SessionManager.open(previousSessionFile);
116
- const previousRootSessionId = previousSession.getSessionId();
117
- return replayRegistryEntries(previousSession.getEntries(), previousRootSessionId);
138
+ function reportInvalidRegistryRecords(
139
+ context: ExtensionContext,
140
+ ): (diagnostics: RegistryReplayDiagnostic[]) => void {
141
+ return (diagnostics) => {
142
+ const summaries = diagnostics
143
+ .slice(0, 3)
144
+ .map(({ entry_index: entryIndex, code }) => `entry ${entryIndex}: ${code}`)
145
+ .join(", ");
146
+ context.ui.notify(
147
+ `Minimal subagents Registry ignored ${diagnostics.length} invalid active-branch record${diagnostics.length === 1 ? "" : "s"} (${summaries}${diagnostics.length > 3 ? ", …" : ""}).`,
148
+ "warning",
149
+ );
150
+ };
151
+ }
152
+
153
+ function interruptSelectedForkBranch(snapshot: RegistrySnapshot): RegistrySnapshot {
154
+ const interrupted = structuredClone(snapshot);
155
+ for (const agent of interrupted.agents) {
156
+ if (!agent.active_turn_id) continue;
157
+ agent.latest_result = {
158
+ agent_id: agent.agent_id,
159
+ turn_id: agent.active_turn_id,
160
+ status: "interrupted",
161
+ output: "",
162
+ error: "Turn interrupted because its source branch was forked",
163
+ };
164
+ agent.active_turn_id = undefined;
165
+ agent.active_turn_started_at = undefined;
166
+ }
167
+ return interrupted;
168
+ }
169
+
170
+ function orderForkAgentsParentFirst(agents: readonly PersistedAgent[]): PersistedAgent[] {
171
+ const byId = new Map(agents.map((agent) => [agent.agent_id, agent]));
172
+ const depth = (agent: PersistedAgent) => {
173
+ let value = 0;
174
+ let parentId = agent.parent_id;
175
+ const visited = new Set<string>();
176
+ while (parentId !== "root" && !visited.has(parentId)) {
177
+ visited.add(parentId);
178
+ value++;
179
+ parentId = byId.get(parentId)?.parent_id ?? "root";
180
+ }
181
+ return value;
182
+ };
183
+ return [...agents].sort((left, right) => depth(left) - depth(right));
184
+ }
185
+
186
+ function unavailableForkAgent(agent: PersistedAgent, error: string): PersistedAgent {
187
+ return {
188
+ ...structuredClone(agent),
189
+ session_file: undefined,
190
+ session_id: undefined,
191
+ session_leaf_id: undefined,
192
+ clone_error: error,
193
+ active_turn_id: undefined,
194
+ active_turn_started_at: undefined,
195
+ availability: "unavailable",
196
+ missing_dependencies: [error],
197
+ unavailable_reason: error,
198
+ };
199
+ }
200
+
201
+ async function cloneSelectedForkSessions(
202
+ sessionFactory: AgentSessionFactory,
203
+ snapshot: RegistrySnapshot,
204
+ sourceRootSessionFile: string,
205
+ sourceRootSessionId: string,
206
+ context: ExtensionContext,
207
+ ): Promise<ForkSnapshot> {
208
+ const agents: PersistedAgent[] = [];
209
+ const failedSubtrees = new Set<string>();
210
+ for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
211
+ const failedAncestor = [...failedSubtrees].find(
212
+ (agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
213
+ );
214
+ if (failedAncestor) {
215
+ agents.push(unavailableForkAgent(agent, `Ancestor clone failed: ${failedAncestor}`));
216
+ continue;
217
+ }
218
+ try {
219
+ const clone = await sessionFactory.cloneForkSourceSession(agent, sourceRootSessionId);
220
+ if (!clone.sessionLeafId) {
221
+ throw new Error(
222
+ `Minimal subagents fork recovery: no selected session leaf for ${agent.agent_id}`,
223
+ );
224
+ }
225
+ agents.push({
226
+ ...structuredClone(agent),
227
+ session_file: clone.sessionFile,
228
+ session_id: clone.sessionId,
229
+ session_leaf_id: clone.sessionLeafId,
230
+ });
231
+ } catch (error) {
232
+ const message = error instanceof Error ? error.message : String(error);
233
+ failedSubtrees.add(agent.agent_id);
234
+ agents.push(unavailableForkAgent(agent, message));
235
+ context.ui.notify(`Fork recovery clone failed for ${agent.agent_id}: ${message}`, "error");
236
+ }
237
+ }
238
+ return {
239
+ ...structuredClone(snapshot),
240
+ source_root_session_file: sourceRootSessionFile,
241
+ source_root_session_id: sourceRootSessionId,
242
+ agents,
243
+ };
244
+ }
245
+
246
+ async function bindForkSnapshotToDestination(
247
+ sessionFactory: AgentSessionFactory,
248
+ snapshot: ForkSnapshot,
249
+ context: ExtensionContext,
250
+ ): Promise<ForkSnapshot> {
251
+ const agents: PersistedAgent[] = [];
252
+ const failedSubtrees = new Set<string>();
253
+ for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
254
+ const failedAncestor = [...failedSubtrees].find(
255
+ (agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
256
+ );
257
+ if (failedAncestor || !agent.session_file || !agent.session_id) {
258
+ agents.push(
259
+ unavailableForkAgent(
260
+ agent,
261
+ agent.clone_error ?? `Ancestor ownership failed: ${failedAncestor ?? agent.agent_id}`,
262
+ ),
263
+ );
264
+ continue;
265
+ }
266
+ try {
267
+ const owned = await sessionFactory.adoptForkSessionOwnership(
268
+ agent,
269
+ snapshot.source_root_session_id,
270
+ );
271
+ if (!owned.sessionLeafId) {
272
+ throw new Error(
273
+ `Minimal subagents fork ownership: no selected session leaf for ${agent.agent_id}`,
274
+ );
275
+ }
276
+ agents.push({
277
+ ...structuredClone(agent),
278
+ session_file: owned.sessionFile,
279
+ session_id: owned.sessionId,
280
+ session_leaf_id: owned.sessionLeafId,
281
+ });
282
+ } catch (error) {
283
+ const message = error instanceof Error ? error.message : String(error);
284
+ failedSubtrees.add(agent.agent_id);
285
+ agents.push(unavailableForkAgent(agent, message));
286
+ context.ui.notify(`Fork ownership failed for ${agent.agent_id}: ${message}`, "error");
287
+ }
288
+ }
289
+ return { ...structuredClone(snapshot), agents };
118
290
  }
119
291
 
120
292
  async function waitForRootSessionIdle(context: ExtensionContext): Promise<void> {
@@ -123,18 +295,49 @@ async function waitForRootSessionIdle(context: ExtensionContext): Promise<void>
123
295
  }
124
296
  }
125
297
 
126
- /** Register the six root coordinator tools and bind root-owned persistent subagent lifecycle hooks. */
127
- export default function minimalSubagentsExtension(pi: ExtensionAPI) {
128
- let coordinator: MinimalSubagentsCoordinator | undefined;
129
- let uiController: MinimalSubagentsUiController | undefined;
130
- let preparedFork: ForkSnapshot | undefined;
298
+ /** SDK and runtime construction effects required by the Minimal Subagents lifecycle controller. */
299
+ export interface MinimalSubagentsLifecycleEffects {
300
+ /** Resolve Pi's current agent directory without coupling lifecycle tests to process configuration. */
301
+ getAgentDirectory(): string;
302
+ /** Construct the Pi child-session adapter after root settings and model scope are resolved. */
303
+ createSessionFactory(options: PiAgentSessionFactoryOptions): AgentSessionFactory;
304
+ }
305
+
306
+ const productionLifecycleEffects: MinimalSubagentsLifecycleEffects = {
307
+ getAgentDirectory: getAgentDir,
308
+ createSessionFactory: (options) => new PiAgentSessionFactory(options),
309
+ };
131
310
 
132
- pi.registerMessageRenderer("minimal-subagents.message", renderMinimalSubagentsMessage);
133
- pi.registerMessageRenderer("minimal-subagents.result", renderMinimalSubagentsResult);
311
+ /** Own coordinator, UI, and prepared-fork state for one root Pi session lifecycle. */
312
+ export class MinimalSubagentsLifecycleController {
313
+ private coordinator: MinimalSubagentsCoordinator | undefined;
314
+ private uiController: MinimalSubagentsUiController | undefined;
315
+ private preparedFork:
316
+ | { sourceSessionFile: string; selectedBranchSnapshot: RegistrySnapshot }
317
+ | undefined;
318
+
319
+ /** Bind one controller to one Pi extension instance and its runtime construction effects. */
320
+ constructor(
321
+ private readonly pi: ExtensionAPI,
322
+ private readonly effects: MinimalSubagentsLifecycleEffects,
323
+ ) {}
324
+
325
+ /** Register renderers and the six Pi lifecycle event handlers owned by Minimal Subagents. */
326
+ register(): void {
327
+ this.pi.registerMessageRenderer("minimal-subagents.message", renderMinimalSubagentsMessage);
328
+ this.pi.registerMessageRenderer("minimal-subagents.result", renderMinimalSubagentsResult);
329
+
330
+ this.pi.on("session_start", (event, context) => this.startSession(event, context));
331
+ this.pi.on("session_before_fork", (event, context) => this.prepareSessionFork(event, context));
332
+ this.pi.on("session_tree", (event, context) => this.restoreSessionTree(event, context));
333
+ this.pi.on("message_end", (event, context) => this.reconcileMessageDelivery(event, context));
334
+ this.pi.on("agent_settled", (event, context) => this.releaseSettledRecipient(event, context));
335
+ this.pi.on("session_shutdown", (event, context) => this.shutdownSession(event, context));
336
+ }
134
337
 
135
- pi.on("session_start", async (event, context) => {
338
+ private async startSession(event: SessionStartEvent, context: ExtensionContext): Promise<void> {
136
339
  const rootSessionId = context.sessionManager.getSessionId();
137
- const agentDir = getAgentDir();
340
+ const agentDir = this.effects.getAgentDirectory();
138
341
  const settingsManager = SettingsManager.create(context.cwd, agentDir, {
139
342
  projectTrusted: context.isProjectTrusted(),
140
343
  });
@@ -164,10 +367,18 @@ export default function minimalSubagentsExtension(pi: ExtensionAPI) {
164
367
  ) {
165
368
  models.push(context.model);
166
369
  }
167
- const availableToolNames = excludeCoordinatorTools(pi.getAllTools().map((tool) => tool.name));
370
+ const availableToolNames = excludeCoordinatorTools(
371
+ this.pi.getAllTools().map((tool) => tool.name),
372
+ );
168
373
  const schemas = createCoordinatorToolSchemas(eligibleModelIds);
169
- let activeCoordinator!: MinimalSubagentsCoordinator;
170
- const sessionFactory = new PiAgentSessionFactory({
374
+ let activeCoordinator: MinimalSubagentsCoordinator | undefined;
375
+ const requireActiveCoordinator = (): MinimalSubagentsCoordinator => {
376
+ if (!activeCoordinator) {
377
+ throw new Error("Minimal subagents lifecycle: coordinator is not initialized");
378
+ }
379
+ return activeCoordinator;
380
+ };
381
+ const sessionFactory = this.effects.createSessionFactory({
171
382
  cwd: context.cwd,
172
383
  agentDir,
173
384
  sessionDir: context.sessionManager.getSessionDir(),
@@ -179,54 +390,94 @@ export default function minimalSubagentsExtension(pi: ExtensionAPI) {
179
390
  availableToolNames,
180
391
  projectTrusted: context.isProjectTrusted(),
181
392
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
182
- onChildSessionActivity: () => activeCoordinator.scheduleDeliveryReconciliation(),
183
- getCoordinatorTools: (callerId) =>
184
- createCoordinatorToolDefinitions({
185
- coordinator: activeCoordinator,
393
+ onChildSessionActivity: () => activeCoordinator?.scheduleDeliveryReconciliation(),
394
+ getCoordinatorTools: (callerId) => {
395
+ const coordinator = requireActiveCoordinator();
396
+ return createCoordinatorToolDefinitions({
397
+ coordinator,
186
398
  callerId,
187
- allowFanoutTools: activeCoordinator.canAgentSpawn(callerId),
399
+ allowFanoutTools: coordinator.canAgentSpawn(callerId),
188
400
  modelRoles: minimalSubagentsConfig.modelRoles,
189
401
  schemas,
190
402
  captureCaller: (childContext) =>
191
- activeCoordinator.snapshotChildCaller(
403
+ coordinator.snapshotChildCaller(
192
404
  callerId,
193
405
  childContext.sessionManager.getLeafId() ?? callerId,
194
406
  ),
195
407
  onAttention: (message) => context.ui.notify(message, "error"),
196
- }),
408
+ });
409
+ },
197
410
  });
198
411
  activeCoordinator = new MinimalSubagentsCoordinator({
199
412
  sessions: sessionFactory,
200
- root: createRootConversationEndpoint(pi, context),
413
+ root: createRootConversationEndpoint(this.pi, context),
201
414
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
202
415
  registry: {
203
416
  rootSessionId,
204
- append: (registryEvent) => pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
417
+ append: (registryEvent) => this.pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
205
418
  },
206
419
  notify: (notification) => {
207
- uiController?.refresh();
420
+ this.uiController?.refresh();
208
421
  if (shouldSurfaceNotification(notification)) {
209
422
  context.ui.notify(notification.message, notificationLevel(notification));
210
423
  }
211
424
  },
212
425
  });
213
- coordinator = activeCoordinator;
426
+ this.coordinator = activeCoordinator;
214
427
 
215
428
  let snapshot: RegistrySnapshot;
216
429
  if (event.reason === "fork" && event.previousSessionFile) {
430
+ const previousSession = SessionManager.open(event.previousSessionFile);
431
+ const sourceRootSessionId = previousSession.getSessionId();
217
432
  let forkSnapshot = takeForkSnapshot(event.previousSessionFile);
433
+ if (forkSnapshot?.source_root_session_id !== sourceRootSessionId) {
434
+ if (forkSnapshot) {
435
+ context.ui.notify(
436
+ "Minimal subagents fork handoff rejected because its source root identity did not match.",
437
+ "error",
438
+ );
439
+ }
440
+ forkSnapshot = undefined;
441
+ }
218
442
  if (!forkSnapshot) {
219
- await activeCoordinator.restore(replayPreviousRoot(event.previousSessionFile));
220
- forkSnapshot = await activeCoordinator.prepareFork(event.previousSessionFile);
443
+ if (
444
+ isForkDestinationForSource(context.sessionManager.getHeader(), event.previousSessionFile)
445
+ ) {
446
+ const selectedSnapshot = interruptSelectedForkBranch(
447
+ replayRegistryEntries(
448
+ context.sessionManager.getBranch(),
449
+ sourceRootSessionId,
450
+ reportInvalidRegistryRecords(context),
451
+ ),
452
+ );
453
+ forkSnapshot = await cloneSelectedForkSessions(
454
+ sessionFactory,
455
+ selectedSnapshot,
456
+ event.previousSessionFile,
457
+ sourceRootSessionId,
458
+ context,
459
+ );
460
+ } else {
461
+ context.ui.notify(
462
+ "Minimal subagents fork recovery skipped because the destination selected branch could not be proven from parentSession provenance.",
463
+ "warning",
464
+ );
465
+ }
221
466
  }
222
- snapshot = forkSnapshot;
467
+ snapshot = forkSnapshot
468
+ ? await bindForkSnapshotToDestination(sessionFactory, forkSnapshot, context)
469
+ : { agents: [], tombstones: [], deliveries: [] };
223
470
  } else {
224
- snapshot = replayRegistryEntries(context.sessionManager.getEntries(), rootSessionId);
471
+ snapshot = replayRegistryEntries(
472
+ context.sessionManager.getBranch(),
473
+ rootSessionId,
474
+ reportInvalidRegistryRecords(context),
475
+ );
225
476
  }
226
477
  await activeCoordinator.restore(snapshot);
227
478
  activeCoordinator.writeCheckpoint();
228
- uiController = new MinimalSubagentsUiController(activeCoordinator, context);
229
- uiController.refresh();
479
+ this.uiController = new MinimalSubagentsUiController(activeCoordinator, context);
480
+ this.uiController.refresh();
230
481
 
231
482
  const rootTools = createCoordinatorToolDefinitions({
232
483
  coordinator: activeCoordinator,
@@ -234,46 +485,113 @@ export default function minimalSubagentsExtension(pi: ExtensionAPI) {
234
485
  allowFanoutTools: true,
235
486
  modelRoles: minimalSubagentsConfig.modelRoles,
236
487
  schemas,
237
- captureCaller: (toolContext) => rootCallerSnapshot(pi, toolContext),
238
- onActivity: () => uiController?.refresh(),
488
+ captureCaller: (toolContext) => rootCallerSnapshot(this.pi, toolContext),
489
+ onActivity: () => this.uiController?.refresh(),
239
490
  onAttention: (message) => context.ui.notify(message, "error"),
240
491
  });
241
- for (const tool of rootTools) pi.registerTool(tool);
242
- pi.setActiveTools([...new Set([...pi.getActiveTools(), ...COORDINATOR_TOOL_NAMES])]);
492
+ for (const tool of rootTools) this.pi.registerTool(tool);
493
+ this.pi.setActiveTools([...new Set([...this.pi.getActiveTools(), ...COORDINATOR_TOOL_NAMES])]);
243
494
 
244
- if (hasHistoricalChildIdentity(context.sessionManager.getEntries() as SessionEntry[])) {
495
+ if (hasHistoricalChildIdentity(context.sessionManager.getBranch())) {
245
496
  context.ui.notify(
246
497
  "Opened a former subagent session directly. It is now an independent root; former descendants and parent messaging were not restored. Concurrent ownership by its original root is unsupported.",
247
498
  "warning",
248
499
  );
249
500
  }
250
- });
501
+ }
251
502
 
252
- pi.on("session_before_fork", async (_event, context) => {
253
- const sessionFile = context.sessionManager.getSessionFile();
254
- if (!coordinator || !sessionFile) return;
255
- preparedFork = await coordinator.prepareFork(sessionFile);
256
- rememberForkSnapshot(preparedFork);
257
- });
503
+ private async prepareSessionFork(
504
+ event: SessionBeforeForkEvent,
505
+ context: ExtensionContext,
506
+ ): Promise<void> {
507
+ const sourceSessionFile = context.sessionManager.getSessionFile();
508
+ if (!sourceSessionFile) {
509
+ this.preparedFork = undefined;
510
+ return;
511
+ }
512
+ const selectedEntry = context.sessionManager.getEntry(event.entryId);
513
+ const selectedLeafId =
514
+ event.position === "before" ? (selectedEntry?.parentId ?? undefined) : event.entryId;
515
+ const selectedBranch =
516
+ event.position === "before" && selectedEntry?.parentId === null
517
+ ? []
518
+ : context.sessionManager.getBranch(selectedLeafId);
519
+ this.preparedFork = {
520
+ sourceSessionFile,
521
+ selectedBranchSnapshot: interruptSelectedForkBranch(
522
+ replayRegistryEntries(
523
+ selectedBranch,
524
+ context.sessionManager.getSessionId(),
525
+ reportInvalidRegistryRecords(context),
526
+ ),
527
+ ),
528
+ };
529
+ }
258
530
 
259
- pi.on("message_end", async (event) => {
260
- if (!coordinator) return;
531
+ private async restoreSessionTree(
532
+ _event: SessionTreeEvent,
533
+ context: ExtensionContext,
534
+ ): Promise<void> {
535
+ if (!this.coordinator) return;
536
+ const snapshot = replayRegistryEntries(
537
+ context.sessionManager.getBranch(),
538
+ context.sessionManager.getSessionId(),
539
+ reportInvalidRegistryRecords(context),
540
+ );
541
+ await this.coordinator.restore(snapshot);
542
+ this.coordinator.writeCheckpoint();
543
+ this.uiController?.refresh();
544
+ }
545
+
546
+ private async reconcileMessageDelivery(
547
+ event: MessageEndEvent,
548
+ _context: ExtensionContext,
549
+ ): Promise<void> {
550
+ if (!this.coordinator) return;
261
551
  if (event.message.role === "toolResult" || event.message.role === "custom") {
262
- await coordinator.reconcileDeliveries();
263
- uiController?.refresh();
552
+ await this.coordinator.reconcileDeliveries();
553
+ this.uiController?.refresh();
264
554
  }
265
- });
555
+ }
266
556
 
267
- pi.on("session_shutdown", async (event, context) => {
268
- if (coordinator) {
269
- await shutdownMinimalSubagentsSession(event.reason, coordinator, {
557
+ private releaseSettledRecipient(_event: AgentSettledEvent, context: ExtensionContext): void {
558
+ if (!this.coordinator) return;
559
+ if (context.isIdle()) this.coordinator.markRecipientIdle("root");
560
+ this.uiController?.refresh();
561
+ }
562
+
563
+ private async shutdownSession(
564
+ event: SessionShutdownEvent,
565
+ context: ExtensionContext,
566
+ ): Promise<void> {
567
+ if (this.coordinator) {
568
+ if (event.reason === "fork" && this.preparedFork) {
569
+ await this.coordinator.restore(this.preparedFork.selectedBranchSnapshot);
570
+ rememberForkSnapshot(
571
+ await this.coordinator.prepareFork(this.preparedFork.sourceSessionFile),
572
+ );
573
+ }
574
+ await shutdownMinimalSubagentsSession(event.reason, this.coordinator, {
270
575
  isRootIdle: () => context.isIdle(),
271
576
  waitForRootIdle: () => waitForRootSessionIdle(context),
272
577
  });
273
578
  }
274
- uiController?.dispose();
275
- uiController = undefined;
276
- coordinator = undefined;
277
- preparedFork = undefined;
278
- });
579
+ this.uiController?.dispose();
580
+ this.uiController = undefined;
581
+ this.coordinator = undefined;
582
+ this.preparedFork = undefined;
583
+ }
584
+ }
585
+
586
+ /** Compose the Minimal Subagents extension with production or faithful SDK runtime effects. */
587
+ export function createMinimalSubagentsExtension(
588
+ effects: MinimalSubagentsLifecycleEffects = productionLifecycleEffects,
589
+ ): ExtensionFactory {
590
+ return (pi) => {
591
+ new MinimalSubagentsLifecycleController(pi, effects).register();
592
+ };
279
593
  }
594
+
595
+ const minimalSubagentsExtension = createMinimalSubagentsExtension();
596
+
597
+ export default minimalSubagentsExtension;
@@ -2,16 +2,14 @@ import { existsSync, realpathSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { ForkSnapshot } from "./minimal-subagents-types.js";
4
4
 
5
- const FORK_SNAPSHOT_SYMBOL = Symbol.for("minimal-subagents.pending-fork-snapshots.v1");
6
-
7
- type GlobalWithForkSnapshots = typeof globalThis & {
8
- [FORK_SNAPSHOT_SYMBOL]?: Map<string, ForkSnapshot>;
9
- };
5
+ declare global {
6
+ // eslint-disable-next-line no-var -- A process-global handoff must be visible to replacement extension instances.
7
+ var minimalSubagentsForkSnapshots: Map<string, ForkSnapshot> | undefined;
8
+ }
10
9
 
11
10
  function forkSnapshotStore(): Map<string, ForkSnapshot> {
12
- const processGlobal = globalThis as GlobalWithForkSnapshots;
13
- processGlobal[FORK_SNAPSHOT_SYMBOL] ??= new Map();
14
- return processGlobal[FORK_SNAPSHOT_SYMBOL];
11
+ globalThis.minimalSubagentsForkSnapshots ??= new Map();
12
+ return globalThis.minimalSubagentsForkSnapshots;
15
13
  }
16
14
 
17
15
  function canonicalSessionFile(sessionFile: string): string {
@@ -19,12 +17,24 @@ function canonicalSessionFile(sessionFile: string): string {
19
17
  return existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
20
18
  }
21
19
 
20
+ /** Prove a process-loss fork destination was derived from the expected canonical source file. */
21
+ export function isForkDestinationForSource(
22
+ destinationHeader: { parentSession?: string } | null,
23
+ previousSessionFile: string,
24
+ ): boolean {
25
+ return (
26
+ destinationHeader?.parentSession !== undefined &&
27
+ canonicalSessionFile(destinationHeader.parentSession) ===
28
+ canonicalSessionFile(previousSessionFile)
29
+ );
30
+ }
31
+
22
32
  /** Retain a complete pre-fork hierarchy across Pi extension-instance replacement. */
23
33
  export function rememberForkSnapshot(snapshot: ForkSnapshot): void {
24
- forkSnapshotStore().set(
25
- canonicalSessionFile(snapshot.source_root_session_file),
26
- structuredClone(snapshot),
27
- );
34
+ const canonicalSourceFile = canonicalSessionFile(snapshot.source_root_session_file);
35
+ const retained = structuredClone(snapshot);
36
+ retained.source_root_session_file = canonicalSourceFile;
37
+ forkSnapshotStore().set(canonicalSourceFile, retained);
28
38
  }
29
39
 
30
40
  /** Consume the pre-fork hierarchy once when the destination root session starts. */
@@ -0,0 +1,20 @@
1
+ import type { CoordinatorMessage } from "./minimal-subagents-types.js";
2
+
3
+ /** Add the stable source identity that the receiving model must see. */
4
+ export function addCoordinatorMessageEnvelope(message: CoordinatorMessage): CoordinatorMessage {
5
+ const kind = message.customType === "minimal-subagents.result" ? "result" : "message";
6
+ const status = message.details.status ? ` | status=${message.details.status}` : "";
7
+ const envelope = `[Subagent ${kind} | agent=${message.details.source_agent_id} | turn=${message.details.source_turn_id}${status}]`;
8
+ return {
9
+ ...message,
10
+ content: message.content.length > 0 ? `${envelope}\n${message.content}` : envelope,
11
+ };
12
+ }
13
+
14
+ /** Remove the model-only envelope from the styled TUI message body. */
15
+ export function stripCoordinatorMessageEnvelope(content: string): string {
16
+ return content.replace(
17
+ /^\[Subagent (?:message|result) \| agent=[^|\]\n]+ \| turn=[^|\]\n]+(?: \| status=[^|\]\n]+)?\]\n?/,
18
+ "",
19
+ );
20
+ }