@ian-pascoe/pi-minimal-subagents 0.1.1 → 0.2.1

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.
@@ -16,8 +16,12 @@ import {
16
16
  SessionManager,
17
17
  SettingsManager,
18
18
  sessionEntryToContextMessages,
19
+ type AgentSessionEvent,
20
+ type SessionEntry,
19
21
  type ToolDefinition,
20
22
  } from "@earendil-works/pi-coding-agent";
23
+ import type { Static, TSchema } from "typebox";
24
+ import { Value } from "typebox/value";
21
25
  import {
22
26
  buildSubagentSystemPrompt,
23
27
  snapshotCommittedContext,
@@ -27,7 +31,20 @@ import {
27
31
  DEFAULT_MAX_SUBAGENT_DEPTH,
28
32
  getSubagentDepth,
29
33
  } from "./minimal-subagents-capabilities.js";
30
- import { CHILD_IDENTITY_ENTRY_TYPE } from "./minimal-subagents-registry.js";
34
+ import {
35
+ CHILD_IDENTITY_ENTRY_TYPE,
36
+ FORK_CLONE_ENTRY_TYPE,
37
+ FORK_OWNERSHIP_ENTRY_TYPE,
38
+ } from "./minimal-subagents-registry.js";
39
+ import {
40
+ ChildSessionIdentityRecordSchema,
41
+ DeliveryEvidenceDetailsSchema,
42
+ ForkCloneProvenanceRecordSchema,
43
+ ForkOwnershipRecordSchema,
44
+ type ChildSessionIdentityRecord,
45
+ type ForkCloneProvenanceRecord,
46
+ type ForkOwnershipRecord,
47
+ } from "./minimal-subagents-session-wire.js";
31
48
  import { addMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
32
49
  import type {
33
50
  AgentSessionFactory,
@@ -36,7 +53,6 @@ import type {
36
53
  PersistedAgent,
37
54
  PersistedSessionIdentity,
38
55
  ProjectContextMode,
39
- RuntimeCreationRequest,
40
56
  RuntimeProfile,
41
57
  RuntimeTurnOutcome,
42
58
  } from "./minimal-subagents-types.js";
@@ -69,6 +85,19 @@ interface ChildResourceLoaderOptionsInput {
69
85
  settingsManager?: SettingsManager;
70
86
  }
71
87
 
88
+ /** Moves one verified child session file to trash and reports command unavailability. */
89
+ export interface SessionFileTrashCapability {
90
+ moveSessionFile(sessionFile: string): Promise<Error | undefined>;
91
+ }
92
+
93
+ const execFileSessionTrashCapability: SessionFileTrashCapability = {
94
+ moveSessionFile: (sessionFile) =>
95
+ new Promise((resolvePromise) => {
96
+ const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
97
+ execFile("trash", trashArguments, (cause) => resolvePromise(cause ?? undefined));
98
+ }),
99
+ };
100
+
72
101
  /** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */
73
102
  export interface PiAgentSessionFactoryOptions {
74
103
  cwd: string;
@@ -82,6 +111,7 @@ export interface PiAgentSessionFactoryOptions {
82
111
  availableToolNames: readonly string[];
83
112
  projectTrusted: boolean;
84
113
  maxSubagentDepth?: number;
114
+ sessionFileTrash?: SessionFileTrashCapability;
85
115
  getCoordinatorTools: (callerId: string) => ToolDefinition[];
86
116
  onChildSessionActivity?: () => void;
87
117
  }
@@ -123,6 +153,7 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
123
153
  );
124
154
  return;
125
155
  }
156
+ // SAFETY: AgentMessage is Pi's broader message union; non-session variants were handled above.
126
157
  sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
127
158
  }
128
159
 
@@ -159,7 +190,148 @@ export function createPersistentChildIdentity(
159
190
  writeFileSync(sessionFile, `${lines}\n`, "utf8");
160
191
  sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd);
161
192
  }
162
- return { sessionFile, sessionId: sessionManager.getSessionId() };
193
+ return {
194
+ sessionFile,
195
+ sessionId: sessionManager.getSessionId(),
196
+ sessionLeafId: sessionManager.getLeafId() ?? undefined,
197
+ };
198
+ }
199
+
200
+ function findLatestChildSessionRecord<TRecordSchema extends TSchema>(
201
+ entries: ReturnType<SessionManager["getBranch"]>,
202
+ customType: string,
203
+ schema: TRecordSchema,
204
+ ): Static<TRecordSchema> | undefined {
205
+ return entries.findLast(
206
+ (
207
+ entry,
208
+ ): entry is Extract<SessionEntry, { type: "custom" }> & {
209
+ data: Static<TRecordSchema>;
210
+ } =>
211
+ entry.type === "custom" && entry.customType === customType && Value.Check(schema, entry.data),
212
+ )?.data;
213
+ }
214
+
215
+ function findLatestForkGeneration(
216
+ entries: ReturnType<SessionManager["getBranch"]>,
217
+ ): { identity: ChildSessionIdentityRecord; provenance: ForkCloneProvenanceRecord } | undefined {
218
+ for (let provenanceIndex = entries.length - 1; provenanceIndex >= 0; provenanceIndex--) {
219
+ const provenanceEntry = entries[provenanceIndex];
220
+ if (
221
+ !provenanceEntry ||
222
+ provenanceEntry.type !== "custom" ||
223
+ provenanceEntry.customType !== FORK_CLONE_ENTRY_TYPE
224
+ ) {
225
+ continue;
226
+ }
227
+ if (!Value.Check(ForkCloneProvenanceRecordSchema, provenanceEntry.data)) continue;
228
+ const provenance = provenanceEntry.data;
229
+ for (let identityIndex = provenanceIndex - 1; identityIndex >= 0; identityIndex--) {
230
+ const identityEntry = entries[identityIndex];
231
+ if (
232
+ !identityEntry ||
233
+ identityEntry.type !== "custom" ||
234
+ identityEntry.customType !== CHILD_IDENTITY_ENTRY_TYPE
235
+ ) {
236
+ continue;
237
+ }
238
+ if (Value.Check(ChildSessionIdentityRecordSchema, identityEntry.data)) {
239
+ return { identity: identityEntry.data, provenance };
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+ return undefined;
245
+ }
246
+
247
+ function findCurrentForkOwnership(
248
+ entries: ReturnType<SessionManager["getBranch"]>,
249
+ cloneSessionId: string,
250
+ ): ForkOwnershipRecord | undefined {
251
+ return entries.findLast(
252
+ (
253
+ entry,
254
+ ): entry is Extract<SessionEntry, { type: "custom" }> & {
255
+ data: ForkOwnershipRecord;
256
+ } =>
257
+ entry.type === "custom" &&
258
+ entry.customType === FORK_OWNERSHIP_ENTRY_TYPE &&
259
+ Value.Check(ForkOwnershipRecordSchema, entry.data) &&
260
+ entry.data.clone_session_id === cloneSessionId,
261
+ )?.data;
262
+ }
263
+
264
+ function verifyForkCloneProvenance(
265
+ branch: ReturnType<SessionManager["getBranch"]>,
266
+ agent: PersistedAgent,
267
+ sourceRootSessionId: string,
268
+ ): ForkCloneProvenanceRecord {
269
+ const provenance = findLatestForkGeneration(branch)?.provenance;
270
+ if (
271
+ !provenance ||
272
+ provenance.source_root_session_id !== sourceRootSessionId ||
273
+ provenance.source_agent_id !== agent.agent_id
274
+ ) {
275
+ throw new Error(
276
+ `Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`,
277
+ );
278
+ }
279
+ return provenance;
280
+ }
281
+
282
+ /** Verify that a persisted child session path belongs to the expected canonical agent and root. */
283
+ export function verifyChildSessionIdentity(
284
+ sessionManager: SessionManager,
285
+ agent: PersistedAgent,
286
+ rootSessionId: string,
287
+ ): void {
288
+ if (sessionManager.getSessionId() !== agent.session_id) {
289
+ throw new Error(
290
+ `Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
291
+ );
292
+ }
293
+ const identityBranch = sessionManager.getBranch(agent.session_leaf_id);
294
+ const generation = findLatestForkGeneration(identityBranch);
295
+ const identity =
296
+ generation?.identity ??
297
+ findLatestChildSessionRecord(
298
+ identityBranch,
299
+ CHILD_IDENTITY_ENTRY_TYPE,
300
+ ChildSessionIdentityRecordSchema,
301
+ );
302
+ if (!identity) {
303
+ throw new Error(`Minimal subagents session identity missing for ${agent.agent_id}`);
304
+ }
305
+ if (
306
+ identity.canonical_agent_id !== agent.agent_id ||
307
+ identity.direct_parent_id !== agent.parent_id ||
308
+ identity.created_at !== agent.created_at
309
+ ) {
310
+ throw new Error(`Minimal subagents session identity mismatch: ownership for ${agent.agent_id}`);
311
+ }
312
+ const ownership = findCurrentForkOwnership(identityBranch, sessionManager.getSessionId());
313
+ if (ownership || identity.original_root_session_id !== rootSessionId) {
314
+ const provenance = generation?.provenance;
315
+ if (
316
+ !ownership ||
317
+ !provenance ||
318
+ ownership.destination_root_session_id !== rootSessionId ||
319
+ ownership.source_root_session_id !== identity.original_root_session_id ||
320
+ ownership.source_root_session_id !== provenance.source_root_session_id ||
321
+ ownership.source_agent_id !== agent.agent_id ||
322
+ ownership.source_agent_id !== provenance.source_agent_id ||
323
+ ownership.source_session_id !== provenance.source_session_id ||
324
+ ownership.clone_session_id !== sessionManager.getSessionId() ||
325
+ ownership.direct_parent_id !== agent.parent_id
326
+ ) {
327
+ throw new Error(
328
+ `Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`,
329
+ );
330
+ }
331
+ }
332
+ if (agent.session_leaf_id && !sessionManager.getEntry(agent.session_leaf_id)) {
333
+ throw new Error(`Minimal subagents session identity mismatch: leaf for ${agent.agent_id}`);
334
+ }
163
335
  }
164
336
 
165
337
  /** Build child resources while filtering recursive coordinator loading and honoring project-context omission. */
@@ -206,29 +378,44 @@ export function createChildResourceLoaderOptions(
206
378
 
207
379
  /** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
208
380
  export function findDeliveryEvidence(
209
- entries: readonly unknown[],
381
+ entries: readonly SessionEntry[],
210
382
  sourceAgentId: string,
211
383
  sourceTurnId: string,
384
+ deliveryId?: string,
212
385
  ): boolean {
213
386
  return entries.some((entry) => {
214
- if (!entry || typeof entry !== "object") return false;
215
- const candidate = entry as {
216
- type?: string;
217
- customType?: string;
218
- details?: unknown;
219
- message?: { role?: string; toolName?: string; details?: unknown };
220
- };
221
- const details =
222
- candidate.type === "custom_message" && candidate.customType === "minimal-subagents.result"
223
- ? candidate.details
224
- : candidate.type === "message" &&
225
- candidate.message?.role === "toolResult" &&
226
- candidate.message.toolName === "subagent_wait"
227
- ? candidate.message.details
228
- : undefined;
229
- if (!details || typeof details !== "object") return false;
230
- const key = details as { source_agent_id?: string; source_turn_id?: string };
231
- return key.source_agent_id === sourceAgentId && key.source_turn_id === sourceTurnId;
387
+ let details: SessionEntry extends infer TEntry
388
+ ? TEntry extends { details?: infer TDetails }
389
+ ? TDetails
390
+ : undefined
391
+ : undefined;
392
+ let waitToolResult = false;
393
+ if (
394
+ entry.type === "custom_message" &&
395
+ (entry.customType === "minimal-subagents.result" ||
396
+ (deliveryId !== undefined && entry.customType === "minimal-subagents.message"))
397
+ ) {
398
+ details = entry.details;
399
+ } else if (
400
+ entry.type === "message" &&
401
+ entry.message.role === "toolResult" &&
402
+ entry.message.toolName === "subagent_wait"
403
+ ) {
404
+ details = entry.message.details;
405
+ waitToolResult = true;
406
+ } else {
407
+ return false;
408
+ }
409
+ if (!Value.Check(DeliveryEvidenceDetailsSchema, details)) return false;
410
+ if (deliveryId !== undefined) {
411
+ return (
412
+ details.source_agent_id === sourceAgentId &&
413
+ details.source_turn_id === sourceTurnId &&
414
+ (details.delivery_id === deliveryId || details.message_id === deliveryId)
415
+ );
416
+ }
417
+ if (waitToolResult && details.event === "message") return false;
418
+ return details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId;
232
419
  });
233
420
  }
234
421
 
@@ -248,6 +435,77 @@ function assistantText(message: AgentMessage | undefined): string {
248
435
  .join("\n");
249
436
  }
250
437
 
438
+ /** Collects finalized turn messages without relying on mutable post-compaction session state. */
439
+ export class ChildTurnOutcomeCollector {
440
+ private readonly messages: AgentMessage[] = [];
441
+ private readonly unsubscribe: () => void;
442
+
443
+ constructor(session: Pick<AgentSession, "subscribe">) {
444
+ this.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
445
+ if (event.type === "message_end") this.messages.push(event.message);
446
+ });
447
+ }
448
+
449
+ dispose(): void {
450
+ this.unsubscribe();
451
+ }
452
+
453
+ toOutcome(aborted: boolean): RuntimeTurnOutcome {
454
+ const finalAssistant = [...this.messages]
455
+ .reverse()
456
+ .find((message) => message.role === "assistant");
457
+ if (!finalAssistant || finalAssistant.role !== "assistant") {
458
+ return {
459
+ status: aborted ? "cancelled" : "failed",
460
+ output: "",
461
+ error: "No terminal assistant response",
462
+ };
463
+ }
464
+ if (finalAssistant.stopReason === "aborted") {
465
+ return {
466
+ status: "cancelled",
467
+ output: assistantText(finalAssistant),
468
+ error: finalAssistant.errorMessage,
469
+ usage: sumUsage(this.messages),
470
+ };
471
+ }
472
+ if (finalAssistant.stopReason === "error") {
473
+ return {
474
+ status: "failed",
475
+ output: assistantText(finalAssistant),
476
+ error: finalAssistant.errorMessage ?? "Provider request failed",
477
+ usage: sumUsage(this.messages),
478
+ };
479
+ }
480
+ return {
481
+ status: "completed",
482
+ output: assistantText(finalAssistant),
483
+ usage: sumUsage(this.messages),
484
+ };
485
+ }
486
+ }
487
+
488
+ /** Run one child operation while retaining its finalized outcome across compaction. */
489
+ export async function captureChildTurnOutcome(
490
+ session: Pick<AgentSession, "subscribe">,
491
+ operation: () => Promise<void>,
492
+ isAborted: () => boolean,
493
+ ): Promise<RuntimeTurnOutcome> {
494
+ const collector = new ChildTurnOutcomeCollector(session);
495
+ try {
496
+ await operation();
497
+ return collector.toOutcome(isAborted());
498
+ } catch (error) {
499
+ return {
500
+ status: isAborted() ? "cancelled" : "failed",
501
+ output: "",
502
+ error: error instanceof Error ? error.message : String(error),
503
+ };
504
+ } finally {
505
+ collector.dispose();
506
+ }
507
+ }
508
+
251
509
  class PiChildAgentRuntime implements ChildAgentRuntime {
252
510
  private aborted = false;
253
511
  private readonly unsubscribe: () => void;
@@ -269,15 +527,8 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
269
527
  });
270
528
  }
271
529
 
272
- get sessionFile(): string {
273
- const sessionFile = this.session.sessionFile;
274
- if (!sessionFile)
275
- throw new Error("Minimal subagents child runtime lost its persistent session file");
276
- return sessionFile;
277
- }
278
-
279
- get sessionId(): string {
280
- return this.session.sessionId;
530
+ get sessionLeafId(): string | undefined {
531
+ return this.session.sessionManager.getLeafId() ?? undefined;
281
532
  }
282
533
 
283
534
  get isRunning(): boolean {
@@ -308,7 +559,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
308
559
  );
309
560
  }
310
561
 
311
- async steerCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
562
+ async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
312
563
  await this.session.sendCustomMessage(
313
564
  {
314
565
  customType: message.customType,
@@ -343,11 +594,12 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
343
594
  return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
344
595
  }
345
596
 
346
- hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean {
597
+ hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
347
598
  return findDeliveryEvidence(
348
- this.session.sessionManager.getEntries(),
599
+ this.session.sessionManager.getBranch(),
349
600
  sourceAgentId,
350
601
  sourceTurnId,
602
+ deliveryId,
351
603
  );
352
604
  }
353
605
 
@@ -355,59 +607,9 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
355
607
  return sumUsage(this.session.messages);
356
608
  }
357
609
 
358
- async cloneSession(): Promise<{ sessionFile: string; sessionId: string }> {
359
- const leafId = this.session.sessionManager.getLeafId();
360
- if (!leafId)
361
- throw new Error(`Minimal subagents fork clone: ${this.sessionId} has no child leaf`);
362
- const sessionFile = this.session.sessionManager.createBranchedSession(leafId);
363
- if (!sessionFile)
364
- throw new Error(`Minimal subagents fork clone: ${this.sessionId} is not persistent`);
365
- return { sessionFile, sessionId: this.session.sessionManager.getSessionId() };
366
- }
367
-
368
610
  private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
369
- const messageStart = this.session.messages.length;
370
611
  this.aborted = false;
371
- try {
372
- await operation();
373
- } catch (error) {
374
- return {
375
- status: this.aborted ? "cancelled" : "failed",
376
- output: "",
377
- error: error instanceof Error ? error.message : String(error),
378
- };
379
- }
380
- const turnMessages = this.session.messages.slice(messageStart);
381
- const finalAssistant = [...turnMessages]
382
- .reverse()
383
- .find((message) => message.role === "assistant");
384
- if (!finalAssistant || finalAssistant.role !== "assistant") {
385
- return {
386
- status: this.aborted ? "cancelled" : "failed",
387
- output: "",
388
- error: "No terminal assistant response",
389
- };
390
- }
391
- if (finalAssistant.stopReason === "aborted") {
392
- return {
393
- status: "cancelled",
394
- output: assistantText(finalAssistant),
395
- error: finalAssistant.errorMessage,
396
- };
397
- }
398
- if (finalAssistant.stopReason === "error") {
399
- return {
400
- status: "failed",
401
- output: assistantText(finalAssistant),
402
- error: finalAssistant.errorMessage ?? "Provider request failed",
403
- usage: sumUsage(turnMessages),
404
- };
405
- }
406
- return {
407
- status: "completed",
408
- output: assistantText(finalAssistant),
409
- usage: sumUsage(turnMessages),
410
- };
612
+ return captureChildTurnOutcome(this.session, operation, () => this.aborted);
411
613
  }
412
614
 
413
615
  private async compactImportedContext(
@@ -449,7 +651,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
449
651
  auth.auth.headers
450
652
  ? Object.fromEntries(
451
653
  Object.entries(auth.auth.headers).filter(
452
- (entry): entry is [string, string] => typeof entry[1] === "string",
654
+ (entry): entry is [string, string] => entry[1] !== null,
453
655
  ),
454
656
  )
455
657
  : undefined,
@@ -483,6 +685,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
483
685
  private readonly eligibleModelIds: Set<string>;
484
686
  private readonly availableToolNames: Set<string>;
485
687
  private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
688
+ private readonly sessionFileTrash: SessionFileTrashCapability;
486
689
 
487
690
  constructor(private readonly options: PiAgentSessionFactoryOptions) {
488
691
  this.modelById = new Map(
@@ -490,6 +693,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
490
693
  );
491
694
  this.eligibleModelIds = new Set(options.eligibleModelIds);
492
695
  this.availableToolNames = new Set(options.availableToolNames);
696
+ this.sessionFileTrash = options.sessionFileTrash ?? execFileSessionTrashCapability;
493
697
  }
494
698
 
495
699
  createIdentity(
@@ -505,14 +709,6 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
505
709
  });
506
710
  }
507
711
 
508
- createRuntime(request: RuntimeCreationRequest): Promise<ChildAgentRuntime> {
509
- return this.openRuntime(request.agent);
510
- }
511
-
512
- restoreRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime> {
513
- return this.openRuntime(agent);
514
- }
515
-
516
712
  resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]> {
517
713
  return this.findMissingDependencies(agent, false);
518
714
  }
@@ -531,27 +727,149 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
531
727
  return this.modelById.get(modelId)?.input.includes("image") ?? false;
532
728
  }
533
729
 
730
+ /** Clone one source-owned child leaf with explicit source-root provenance. */
534
731
  async cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity> {
535
- if (!agent.session_file) {
732
+ return this.cloneSessionOwnedByRoot(agent, this.options.rootSessionId);
733
+ }
734
+
735
+ /** Recover one proven selected child leaf after the source process handoff was lost. */
736
+ async cloneForkSourceSession(
737
+ agent: PersistedAgent,
738
+ sourceRootSessionId: string,
739
+ ): Promise<PersistedSessionIdentity> {
740
+ return this.cloneSessionOwnedByRoot(agent, sourceRootSessionId);
741
+ }
742
+
743
+ /** Bind one verified fork clone exclusively to this factory's destination root. */
744
+ async adoptForkSessionOwnership(
745
+ agent: PersistedAgent,
746
+ sourceRootSessionId: string,
747
+ ): Promise<PersistedSessionIdentity> {
748
+ if (!agent.session_file || !agent.session_id) {
749
+ throw new Error(`Minimal subagents fork ownership: ${agent.agent_id} has no clone session`);
750
+ }
751
+ const sessionFile = canonicalPath(agent.session_file);
752
+ const sessionManager = SessionManager.open(
753
+ sessionFile,
754
+ this.options.sessionDir,
755
+ this.options.cwd,
756
+ );
757
+ if (sessionManager.getSessionId() !== agent.session_id) {
758
+ throw new Error(
759
+ `Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
760
+ );
761
+ }
762
+ const branch = sessionManager.getBranch(agent.session_leaf_id);
763
+ const generation = findLatestForkGeneration(branch);
764
+ const identity =
765
+ generation?.identity ??
766
+ findLatestChildSessionRecord(
767
+ branch,
768
+ CHILD_IDENTITY_ENTRY_TYPE,
769
+ ChildSessionIdentityRecordSchema,
770
+ );
771
+ if (
772
+ !identity ||
773
+ identity.original_root_session_id !== sourceRootSessionId ||
774
+ identity.canonical_agent_id !== agent.agent_id ||
775
+ identity.direct_parent_id !== agent.parent_id ||
776
+ identity.created_at !== agent.created_at
777
+ ) {
778
+ throw new Error(
779
+ `Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`,
780
+ );
781
+ }
782
+ const provenance = verifyForkCloneProvenance(branch, agent, sourceRootSessionId);
783
+ const existingOwnership = findCurrentForkOwnership(branch, sessionManager.getSessionId());
784
+ if (existingOwnership) {
785
+ if (
786
+ existingOwnership.source_root_session_id !== sourceRootSessionId ||
787
+ existingOwnership.destination_root_session_id !== this.options.rootSessionId ||
788
+ existingOwnership.source_agent_id !== agent.agent_id ||
789
+ existingOwnership.source_session_id !== provenance.source_session_id ||
790
+ existingOwnership.direct_parent_id !== agent.parent_id
791
+ ) {
792
+ throw new Error(
793
+ `Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`,
794
+ );
795
+ }
796
+ } else {
797
+ sessionManager.appendCustomEntry(FORK_OWNERSHIP_ENTRY_TYPE, {
798
+ version: 1,
799
+ source_root_session_id: sourceRootSessionId,
800
+ destination_root_session_id: this.options.rootSessionId,
801
+ source_agent_id: agent.agent_id,
802
+ source_session_id: provenance.source_session_id,
803
+ clone_session_id: sessionManager.getSessionId(),
804
+ direct_parent_id: agent.parent_id,
805
+ });
806
+ }
807
+ return {
808
+ sessionFile,
809
+ sessionId: sessionManager.getSessionId(),
810
+ sessionLeafId: sessionManager.getLeafId() ?? undefined,
811
+ };
812
+ }
813
+
814
+ async trashSession(agent: PersistedAgent): Promise<void> {
815
+ if (!agent.session_file) return;
816
+ const sessionFile = canonicalPath(agent.session_file);
817
+ const sessionManager = SessionManager.open(
818
+ sessionFile,
819
+ this.options.sessionDir,
820
+ this.options.cwd,
821
+ );
822
+ verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
823
+ const trashError = await this.sessionFileTrash.moveSessionFile(sessionFile);
824
+ if (!trashError || !existsSync(sessionFile)) return;
825
+
826
+ try {
827
+ await unlink(sessionFile);
828
+ } catch (error) {
829
+ const unlinkError = error instanceof Error ? error.message : String(error);
830
+ throw new Error(
831
+ `Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
832
+ );
833
+ }
834
+ }
835
+
836
+ private async cloneSessionOwnedByRoot(
837
+ agent: PersistedAgent,
838
+ sourceRootSessionId: string,
839
+ ): Promise<PersistedSessionIdentity> {
840
+ if (!agent.session_file || !agent.session_id) {
536
841
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`);
537
842
  }
538
843
  const source = SessionManager.open(
539
- agent.session_file,
844
+ canonicalPath(agent.session_file),
540
845
  this.options.sessionDir,
541
846
  this.options.cwd,
542
847
  );
543
- const leafId = source.getLeafId();
544
- if (!leafId)
848
+ verifyChildSessionIdentity(source, agent, sourceRootSessionId);
849
+ const leafId = agent.session_leaf_id ?? source.getLeafId();
850
+ if (!leafId || !source.getEntry(leafId))
545
851
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`);
546
852
  const sessionFile = source.createBranchedSession(leafId);
547
853
  if (!sessionFile)
548
854
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`);
549
- source.appendCustomEntry("minimal-subagents.fork-clone", {
855
+ // createBranchedSession mutates this manager to the new session even when Pi defers
856
+ // writing an identity-only branch until its first assistant response.
857
+ const clone = source;
858
+ clone.appendCustomEntry(CHILD_IDENTITY_ENTRY_TYPE, {
859
+ version: 1,
860
+ original_root_session_id: sourceRootSessionId,
861
+ canonical_agent_id: agent.agent_id,
862
+ direct_parent_id: agent.parent_id,
863
+ created_at: agent.created_at,
864
+ });
865
+ clone.appendCustomEntry(FORK_CLONE_ENTRY_TYPE, {
866
+ version: 1,
867
+ source_root_session_id: sourceRootSessionId,
550
868
  source_agent_id: agent.agent_id,
551
869
  source_session_id: agent.session_id,
552
870
  });
553
871
  if (!existsSync(sessionFile)) {
554
- const lines = [source.getHeader(), ...source.getEntries()]
872
+ const lines = [clone.getHeader(), ...clone.getEntries()]
555
873
  .filter((entry) => entry !== null)
556
874
  .map((entry) => JSON.stringify(entry))
557
875
  .join("\n");
@@ -560,24 +878,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
560
878
  if (!existsSync(sessionFile)) {
561
879
  throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`);
562
880
  }
563
- return { sessionFile, sessionId: source.getSessionId() };
564
- }
565
-
566
- async trashSessionFile(sessionFile: string): Promise<void> {
567
- const trashError = await new Promise<Error | undefined>((resolvePromise) => {
568
- const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
569
- execFile("trash", trashArguments, (error) => resolvePromise(error ?? undefined));
570
- });
571
- if (!trashError || !existsSync(sessionFile)) return;
572
-
573
- try {
574
- await unlink(sessionFile);
575
- } catch (error) {
576
- const unlinkError = error instanceof Error ? error.message : String(error);
577
- throw new Error(
578
- `Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
579
- );
580
- }
881
+ return {
882
+ sessionFile,
883
+ sessionId: clone.getSessionId(),
884
+ sessionLeafId: clone.getLeafId() ?? undefined,
885
+ };
581
886
  }
582
887
 
583
888
  private buildChildSystemPrompt(agent: PersistedAgent): string {
@@ -643,7 +948,8 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
643
948
  return discovery;
644
949
  }
645
950
 
646
- private async openRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime> {
951
+ /** Open one verified persisted Child Agent runtime for launch or restoration. */
952
+ async openRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime> {
647
953
  if (!agent.session_file)
648
954
  throw new Error(`Minimal subagents restore: ${agent.agent_id} has no session file`);
649
955
  const model = this.modelById.get(agent.launch_contract.model);
@@ -674,10 +980,12 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
674
980
  modelsPath: resolve(this.options.agentDir, "models.json"),
675
981
  });
676
982
  const sessionManager = SessionManager.open(
677
- agent.session_file,
983
+ canonicalPath(agent.session_file),
678
984
  this.options.sessionDir,
679
985
  this.options.cwd,
680
986
  );
987
+ verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
988
+ if (agent.session_leaf_id) sessionManager.branch(agent.session_leaf_id);
681
989
  const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id);
682
990
  const allowedToolNames = [
683
991
  ...agent.launch_contract.ordinary_tools,