@ian-pascoe/pi-minimal-subagents 0.1.1 → 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.
@@ -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,
@@ -69,6 +86,19 @@ interface ChildResourceLoaderOptionsInput {
69
86
  settingsManager?: SettingsManager;
70
87
  }
71
88
 
89
+ /** Moves one verified child session file to trash and reports command unavailability. */
90
+ export interface SessionFileTrashCapability {
91
+ moveSessionFile(sessionFile: string): Promise<Error | undefined>;
92
+ }
93
+
94
+ const execFileSessionTrashCapability: SessionFileTrashCapability = {
95
+ moveSessionFile: (sessionFile) =>
96
+ new Promise((resolvePromise) => {
97
+ const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
98
+ execFile("trash", trashArguments, (cause) => resolvePromise(cause ?? undefined));
99
+ }),
100
+ };
101
+
72
102
  /** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */
73
103
  export interface PiAgentSessionFactoryOptions {
74
104
  cwd: string;
@@ -82,6 +112,7 @@ export interface PiAgentSessionFactoryOptions {
82
112
  availableToolNames: readonly string[];
83
113
  projectTrusted: boolean;
84
114
  maxSubagentDepth?: number;
115
+ sessionFileTrash?: SessionFileTrashCapability;
85
116
  getCoordinatorTools: (callerId: string) => ToolDefinition[];
86
117
  onChildSessionActivity?: () => void;
87
118
  }
@@ -123,6 +154,7 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
123
154
  );
124
155
  return;
125
156
  }
157
+ // SAFETY: AgentMessage is Pi's broader message union; non-session variants were handled above.
126
158
  sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
127
159
  }
128
160
 
@@ -159,7 +191,147 @@ export function createPersistentChildIdentity(
159
191
  writeFileSync(sessionFile, `${lines}\n`, "utf8");
160
192
  sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd);
161
193
  }
162
- return { sessionFile, sessionId: sessionManager.getSessionId() };
194
+ return {
195
+ sessionFile,
196
+ sessionId: sessionManager.getSessionId(),
197
+ sessionLeafId: sessionManager.getLeafId() ?? undefined,
198
+ };
199
+ }
200
+
201
+ function findLatestChildSessionRecord<TRecordSchema extends TSchema>(
202
+ entries: ReturnType<SessionManager["getBranch"]>,
203
+ customType: string,
204
+ schema: TRecordSchema,
205
+ ): Static<TRecordSchema> | undefined {
206
+ for (let index = entries.length - 1; index >= 0; index--) {
207
+ const entry = entries[index];
208
+ if (!entry || entry.type !== "custom" || entry.customType !== customType) continue;
209
+ if (Value.Check(schema, entry.data)) return entry.data;
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ function findLatestForkGeneration(
215
+ entries: ReturnType<SessionManager["getBranch"]>,
216
+ ): { identity: ChildSessionIdentityRecord; provenance: ForkCloneProvenanceRecord } | undefined {
217
+ for (let provenanceIndex = entries.length - 1; provenanceIndex >= 0; provenanceIndex--) {
218
+ const provenanceEntry = entries[provenanceIndex];
219
+ if (
220
+ !provenanceEntry ||
221
+ provenanceEntry.type !== "custom" ||
222
+ provenanceEntry.customType !== FORK_CLONE_ENTRY_TYPE
223
+ ) {
224
+ continue;
225
+ }
226
+ if (!Value.Check(ForkCloneProvenanceRecordSchema, provenanceEntry.data)) continue;
227
+ const provenance = provenanceEntry.data;
228
+ for (let identityIndex = provenanceIndex - 1; identityIndex >= 0; identityIndex--) {
229
+ const identityEntry = entries[identityIndex];
230
+ if (
231
+ !identityEntry ||
232
+ identityEntry.type !== "custom" ||
233
+ identityEntry.customType !== CHILD_IDENTITY_ENTRY_TYPE
234
+ ) {
235
+ continue;
236
+ }
237
+ if (Value.Check(ChildSessionIdentityRecordSchema, identityEntry.data)) {
238
+ return { identity: identityEntry.data, provenance };
239
+ }
240
+ }
241
+ return undefined;
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ function findCurrentForkOwnership(
247
+ entries: ReturnType<SessionManager["getBranch"]>,
248
+ cloneSessionId: string,
249
+ ): ForkOwnershipRecord | undefined {
250
+ for (let index = entries.length - 1; index >= 0; index--) {
251
+ const entry = entries[index];
252
+ if (!entry || entry.type !== "custom" || entry.customType !== FORK_OWNERSHIP_ENTRY_TYPE)
253
+ continue;
254
+ if (
255
+ Value.Check(ForkOwnershipRecordSchema, entry.data) &&
256
+ entry.data.clone_session_id === cloneSessionId
257
+ ) {
258
+ return entry.data;
259
+ }
260
+ }
261
+ return undefined;
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;
@@ -280,6 +538,10 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
280
538
  return this.session.sessionId;
281
539
  }
282
540
 
541
+ get sessionLeafId(): string | undefined {
542
+ return this.session.sessionManager.getLeafId() ?? undefined;
543
+ }
544
+
283
545
  get isRunning(): boolean {
284
546
  return this.session.isStreaming;
285
547
  }
@@ -308,7 +570,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
308
570
  );
309
571
  }
310
572
 
311
- async steerCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
573
+ async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
312
574
  await this.session.sendCustomMessage(
313
575
  {
314
576
  customType: message.customType,
@@ -343,11 +605,12 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
343
605
  return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
344
606
  }
345
607
 
346
- hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean {
608
+ hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
347
609
  return findDeliveryEvidence(
348
- this.session.sessionManager.getEntries(),
610
+ this.session.sessionManager.getBranch(),
349
611
  sourceAgentId,
350
612
  sourceTurnId,
613
+ deliveryId,
351
614
  );
352
615
  }
353
616
 
@@ -355,59 +618,9 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
355
618
  return sumUsage(this.session.messages);
356
619
  }
357
620
 
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
621
  private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
369
- const messageStart = this.session.messages.length;
370
622
  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
- };
623
+ return captureChildTurnOutcome(this.session, operation, () => this.aborted);
411
624
  }
412
625
 
413
626
  private async compactImportedContext(
@@ -449,7 +662,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
449
662
  auth.auth.headers
450
663
  ? Object.fromEntries(
451
664
  Object.entries(auth.auth.headers).filter(
452
- (entry): entry is [string, string] => typeof entry[1] === "string",
665
+ (entry): entry is [string, string] => entry[1] !== null,
453
666
  ),
454
667
  )
455
668
  : undefined,
@@ -483,6 +696,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
483
696
  private readonly eligibleModelIds: Set<string>;
484
697
  private readonly availableToolNames: Set<string>;
485
698
  private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
699
+ private readonly sessionFileTrash: SessionFileTrashCapability;
486
700
 
487
701
  constructor(private readonly options: PiAgentSessionFactoryOptions) {
488
702
  this.modelById = new Map(
@@ -490,6 +704,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
490
704
  );
491
705
  this.eligibleModelIds = new Set(options.eligibleModelIds);
492
706
  this.availableToolNames = new Set(options.availableToolNames);
707
+ this.sessionFileTrash = options.sessionFileTrash ?? execFileSessionTrashCapability;
493
708
  }
494
709
 
495
710
  createIdentity(
@@ -531,27 +746,149 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
531
746
  return this.modelById.get(modelId)?.input.includes("image") ?? false;
532
747
  }
533
748
 
749
+ /** Clone one source-owned child leaf with explicit source-root provenance. */
534
750
  async cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity> {
535
- if (!agent.session_file) {
751
+ return this.cloneSessionOwnedByRoot(agent, this.options.rootSessionId);
752
+ }
753
+
754
+ /** Recover one proven selected child leaf after the source process handoff was lost. */
755
+ async cloneForkSourceSession(
756
+ agent: PersistedAgent,
757
+ sourceRootSessionId: string,
758
+ ): Promise<PersistedSessionIdentity> {
759
+ return this.cloneSessionOwnedByRoot(agent, sourceRootSessionId);
760
+ }
761
+
762
+ /** Bind one verified fork clone exclusively to this factory's destination root. */
763
+ async adoptForkSessionOwnership(
764
+ agent: PersistedAgent,
765
+ sourceRootSessionId: string,
766
+ ): Promise<PersistedSessionIdentity> {
767
+ if (!agent.session_file || !agent.session_id) {
768
+ throw new Error(`Minimal subagents fork ownership: ${agent.agent_id} has no clone session`);
769
+ }
770
+ const sessionFile = canonicalPath(agent.session_file);
771
+ const sessionManager = SessionManager.open(
772
+ sessionFile,
773
+ this.options.sessionDir,
774
+ this.options.cwd,
775
+ );
776
+ if (sessionManager.getSessionId() !== agent.session_id) {
777
+ throw new Error(
778
+ `Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
779
+ );
780
+ }
781
+ const branch = sessionManager.getBranch(agent.session_leaf_id);
782
+ const generation = findLatestForkGeneration(branch);
783
+ const identity =
784
+ generation?.identity ??
785
+ findLatestChildSessionRecord(
786
+ branch,
787
+ CHILD_IDENTITY_ENTRY_TYPE,
788
+ ChildSessionIdentityRecordSchema,
789
+ );
790
+ if (
791
+ !identity ||
792
+ identity.original_root_session_id !== sourceRootSessionId ||
793
+ identity.canonical_agent_id !== agent.agent_id ||
794
+ identity.direct_parent_id !== agent.parent_id ||
795
+ identity.created_at !== agent.created_at
796
+ ) {
797
+ throw new Error(
798
+ `Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`,
799
+ );
800
+ }
801
+ const provenance = verifyForkCloneProvenance(branch, agent, sourceRootSessionId);
802
+ const existingOwnership = findCurrentForkOwnership(branch, sessionManager.getSessionId());
803
+ if (existingOwnership) {
804
+ if (
805
+ existingOwnership.source_root_session_id !== sourceRootSessionId ||
806
+ existingOwnership.destination_root_session_id !== this.options.rootSessionId ||
807
+ existingOwnership.source_agent_id !== agent.agent_id ||
808
+ existingOwnership.source_session_id !== provenance.source_session_id ||
809
+ existingOwnership.direct_parent_id !== agent.parent_id
810
+ ) {
811
+ throw new Error(
812
+ `Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`,
813
+ );
814
+ }
815
+ } else {
816
+ sessionManager.appendCustomEntry(FORK_OWNERSHIP_ENTRY_TYPE, {
817
+ version: 1,
818
+ source_root_session_id: sourceRootSessionId,
819
+ destination_root_session_id: this.options.rootSessionId,
820
+ source_agent_id: agent.agent_id,
821
+ source_session_id: provenance.source_session_id,
822
+ clone_session_id: sessionManager.getSessionId(),
823
+ direct_parent_id: agent.parent_id,
824
+ });
825
+ }
826
+ return {
827
+ sessionFile,
828
+ sessionId: sessionManager.getSessionId(),
829
+ sessionLeafId: sessionManager.getLeafId() ?? undefined,
830
+ };
831
+ }
832
+
833
+ async trashSession(agent: PersistedAgent): Promise<void> {
834
+ if (!agent.session_file) return;
835
+ const sessionFile = canonicalPath(agent.session_file);
836
+ const sessionManager = SessionManager.open(
837
+ sessionFile,
838
+ this.options.sessionDir,
839
+ this.options.cwd,
840
+ );
841
+ verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
842
+ const trashError = await this.sessionFileTrash.moveSessionFile(sessionFile);
843
+ if (!trashError || !existsSync(sessionFile)) return;
844
+
845
+ try {
846
+ await unlink(sessionFile);
847
+ } catch (error) {
848
+ const unlinkError = error instanceof Error ? error.message : String(error);
849
+ throw new Error(
850
+ `Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
851
+ );
852
+ }
853
+ }
854
+
855
+ private async cloneSessionOwnedByRoot(
856
+ agent: PersistedAgent,
857
+ sourceRootSessionId: string,
858
+ ): Promise<PersistedSessionIdentity> {
859
+ if (!agent.session_file || !agent.session_id) {
536
860
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`);
537
861
  }
538
862
  const source = SessionManager.open(
539
- agent.session_file,
863
+ canonicalPath(agent.session_file),
540
864
  this.options.sessionDir,
541
865
  this.options.cwd,
542
866
  );
543
- const leafId = source.getLeafId();
544
- if (!leafId)
867
+ verifyChildSessionIdentity(source, agent, sourceRootSessionId);
868
+ const leafId = agent.session_leaf_id ?? source.getLeafId();
869
+ if (!leafId || !source.getEntry(leafId))
545
870
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`);
546
871
  const sessionFile = source.createBranchedSession(leafId);
547
872
  if (!sessionFile)
548
873
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`);
549
- source.appendCustomEntry("minimal-subagents.fork-clone", {
874
+ // createBranchedSession mutates this manager to the new session even when Pi defers
875
+ // writing an identity-only branch until its first assistant response.
876
+ const clone = source;
877
+ clone.appendCustomEntry(CHILD_IDENTITY_ENTRY_TYPE, {
878
+ version: 1,
879
+ original_root_session_id: sourceRootSessionId,
880
+ canonical_agent_id: agent.agent_id,
881
+ direct_parent_id: agent.parent_id,
882
+ created_at: agent.created_at,
883
+ });
884
+ clone.appendCustomEntry(FORK_CLONE_ENTRY_TYPE, {
885
+ version: 1,
886
+ source_root_session_id: sourceRootSessionId,
550
887
  source_agent_id: agent.agent_id,
551
888
  source_session_id: agent.session_id,
552
889
  });
553
890
  if (!existsSync(sessionFile)) {
554
- const lines = [source.getHeader(), ...source.getEntries()]
891
+ const lines = [clone.getHeader(), ...clone.getEntries()]
555
892
  .filter((entry) => entry !== null)
556
893
  .map((entry) => JSON.stringify(entry))
557
894
  .join("\n");
@@ -560,24 +897,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
560
897
  if (!existsSync(sessionFile)) {
561
898
  throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`);
562
899
  }
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
- }
900
+ return {
901
+ sessionFile,
902
+ sessionId: clone.getSessionId(),
903
+ sessionLeafId: clone.getLeafId() ?? undefined,
904
+ };
581
905
  }
582
906
 
583
907
  private buildChildSystemPrompt(agent: PersistedAgent): string {
@@ -674,10 +998,12 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
674
998
  modelsPath: resolve(this.options.agentDir, "models.json"),
675
999
  });
676
1000
  const sessionManager = SessionManager.open(
677
- agent.session_file,
1001
+ canonicalPath(agent.session_file),
678
1002
  this.options.sessionDir,
679
1003
  this.options.cwd,
680
1004
  );
1005
+ verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
1006
+ if (agent.session_leaf_id) sessionManager.branch(agent.session_leaf_id);
681
1007
  const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id);
682
1008
  const allowedToolNames = [
683
1009
  ...agent.launch_contract.ordinary_tools,
@@ -21,9 +21,9 @@ export async function shutdownMinimalSubagentsSession(
21
21
  }
22
22
 
23
23
  while (true) {
24
+ if (!rootIdleGate.isRootIdle()) await rootIdleGate.waitForRootIdle();
24
25
  await coordinator.waitForSettledOperations();
25
26
  if (rootIdleGate.isRootIdle()) break;
26
- await rootIdleGate.waitForRootIdle();
27
27
  }
28
28
  await coordinator.shutdownAfterSettling();
29
29
  }