@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.
@@ -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,
@@ -37,6 +54,7 @@ import type {
37
54
  PersistedSessionIdentity,
38
55
  ProjectContextMode,
39
56
  RuntimeCreationRequest,
57
+ RuntimeProfile,
40
58
  RuntimeTurnOutcome,
41
59
  } from "./minimal-subagents-types.js";
42
60
 
@@ -68,6 +86,19 @@ interface ChildResourceLoaderOptionsInput {
68
86
  settingsManager?: SettingsManager;
69
87
  }
70
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
+
71
102
  /** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */
72
103
  export interface PiAgentSessionFactoryOptions {
73
104
  cwd: string;
@@ -81,6 +112,7 @@ export interface PiAgentSessionFactoryOptions {
81
112
  availableToolNames: readonly string[];
82
113
  projectTrusted: boolean;
83
114
  maxSubagentDepth?: number;
115
+ sessionFileTrash?: SessionFileTrashCapability;
84
116
  getCoordinatorTools: (callerId: string) => ToolDefinition[];
85
117
  onChildSessionActivity?: () => void;
86
118
  }
@@ -122,6 +154,7 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
122
154
  );
123
155
  return;
124
156
  }
157
+ // SAFETY: AgentMessage is Pi's broader message union; non-session variants were handled above.
125
158
  sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
126
159
  }
127
160
 
@@ -158,7 +191,147 @@ export function createPersistentChildIdentity(
158
191
  writeFileSync(sessionFile, `${lines}\n`, "utf8");
159
192
  sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd);
160
193
  }
161
- 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
+ }
162
335
  }
163
336
 
164
337
  /** Build child resources while filtering recursive coordinator loading and honoring project-context omission. */
@@ -205,29 +378,44 @@ export function createChildResourceLoaderOptions(
205
378
 
206
379
  /** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
207
380
  export function findDeliveryEvidence(
208
- entries: readonly unknown[],
381
+ entries: readonly SessionEntry[],
209
382
  sourceAgentId: string,
210
383
  sourceTurnId: string,
384
+ deliveryId?: string,
211
385
  ): boolean {
212
386
  return entries.some((entry) => {
213
- if (!entry || typeof entry !== "object") return false;
214
- const candidate = entry as {
215
- type?: string;
216
- customType?: string;
217
- details?: unknown;
218
- message?: { role?: string; toolName?: string; details?: unknown };
219
- };
220
- const details =
221
- candidate.type === "custom_message" && candidate.customType === "minimal-subagents.result"
222
- ? candidate.details
223
- : candidate.type === "message" &&
224
- candidate.message?.role === "toolResult" &&
225
- candidate.message.toolName === "subagent_wait"
226
- ? candidate.message.details
227
- : undefined;
228
- if (!details || typeof details !== "object") return false;
229
- const key = details as { source_agent_id?: string; source_turn_id?: string };
230
- 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;
231
419
  });
232
420
  }
233
421
 
@@ -247,6 +435,77 @@ function assistantText(message: AgentMessage | undefined): string {
247
435
  .join("\n");
248
436
  }
249
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
+
250
509
  class PiChildAgentRuntime implements ChildAgentRuntime {
251
510
  private aborted = false;
252
511
  private readonly unsubscribe: () => void;
@@ -279,6 +538,10 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
279
538
  return this.session.sessionId;
280
539
  }
281
540
 
541
+ get sessionLeafId(): string | undefined {
542
+ return this.session.sessionManager.getLeafId() ?? undefined;
543
+ }
544
+
282
545
  get isRunning(): boolean {
283
546
  return this.session.isStreaming;
284
547
  }
@@ -307,7 +570,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
307
570
  );
308
571
  }
309
572
 
310
- async steerCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
573
+ async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
311
574
  await this.session.sendCustomMessage(
312
575
  {
313
576
  customType: message.customType,
@@ -329,15 +592,25 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
329
592
  this.session.dispose();
330
593
  }
331
594
 
595
+ getRuntimeProfile(): RuntimeProfile | undefined {
596
+ const model = this.session.model;
597
+ if (!model) return undefined;
598
+ return {
599
+ model: `${model.provider}/${model.id}`,
600
+ thinking_level: this.session.thinkingLevel,
601
+ };
602
+ }
603
+
332
604
  snapshotCommittedMessages(): AgentMessage[] {
333
605
  return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
334
606
  }
335
607
 
336
- hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean {
608
+ hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
337
609
  return findDeliveryEvidence(
338
- this.session.sessionManager.getEntries(),
610
+ this.session.sessionManager.getBranch(),
339
611
  sourceAgentId,
340
612
  sourceTurnId,
613
+ deliveryId,
341
614
  );
342
615
  }
343
616
 
@@ -345,59 +618,9 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
345
618
  return sumUsage(this.session.messages);
346
619
  }
347
620
 
348
- async cloneSession(): Promise<{ sessionFile: string; sessionId: string }> {
349
- const leafId = this.session.sessionManager.getLeafId();
350
- if (!leafId)
351
- throw new Error(`Minimal subagents fork clone: ${this.sessionId} has no child leaf`);
352
- const sessionFile = this.session.sessionManager.createBranchedSession(leafId);
353
- if (!sessionFile)
354
- throw new Error(`Minimal subagents fork clone: ${this.sessionId} is not persistent`);
355
- return { sessionFile, sessionId: this.session.sessionManager.getSessionId() };
356
- }
357
-
358
621
  private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
359
- const messageStart = this.session.messages.length;
360
622
  this.aborted = false;
361
- try {
362
- await operation();
363
- } catch (error) {
364
- return {
365
- status: this.aborted ? "cancelled" : "failed",
366
- output: "",
367
- error: error instanceof Error ? error.message : String(error),
368
- };
369
- }
370
- const turnMessages = this.session.messages.slice(messageStart);
371
- const finalAssistant = [...turnMessages]
372
- .reverse()
373
- .find((message) => message.role === "assistant");
374
- if (!finalAssistant || finalAssistant.role !== "assistant") {
375
- return {
376
- status: this.aborted ? "cancelled" : "failed",
377
- output: "",
378
- error: "No terminal assistant response",
379
- };
380
- }
381
- if (finalAssistant.stopReason === "aborted") {
382
- return {
383
- status: "cancelled",
384
- output: assistantText(finalAssistant),
385
- error: finalAssistant.errorMessage,
386
- };
387
- }
388
- if (finalAssistant.stopReason === "error") {
389
- return {
390
- status: "failed",
391
- output: assistantText(finalAssistant),
392
- error: finalAssistant.errorMessage ?? "Provider request failed",
393
- usage: sumUsage(turnMessages),
394
- };
395
- }
396
- return {
397
- status: "completed",
398
- output: assistantText(finalAssistant),
399
- usage: sumUsage(turnMessages),
400
- };
623
+ return captureChildTurnOutcome(this.session, operation, () => this.aborted);
401
624
  }
402
625
 
403
626
  private async compactImportedContext(
@@ -439,7 +662,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
439
662
  auth.auth.headers
440
663
  ? Object.fromEntries(
441
664
  Object.entries(auth.auth.headers).filter(
442
- (entry): entry is [string, string] => typeof entry[1] === "string",
665
+ (entry): entry is [string, string] => entry[1] !== null,
443
666
  ),
444
667
  )
445
668
  : undefined,
@@ -473,6 +696,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
473
696
  private readonly eligibleModelIds: Set<string>;
474
697
  private readonly availableToolNames: Set<string>;
475
698
  private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
699
+ private readonly sessionFileTrash: SessionFileTrashCapability;
476
700
 
477
701
  constructor(private readonly options: PiAgentSessionFactoryOptions) {
478
702
  this.modelById = new Map(
@@ -480,6 +704,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
480
704
  );
481
705
  this.eligibleModelIds = new Set(options.eligibleModelIds);
482
706
  this.availableToolNames = new Set(options.availableToolNames);
707
+ this.sessionFileTrash = options.sessionFileTrash ?? execFileSessionTrashCapability;
483
708
  }
484
709
 
485
710
  createIdentity(
@@ -521,27 +746,149 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
521
746
  return this.modelById.get(modelId)?.input.includes("image") ?? false;
522
747
  }
523
748
 
749
+ /** Clone one source-owned child leaf with explicit source-root provenance. */
524
750
  async cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity> {
525
- 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) {
526
860
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`);
527
861
  }
528
862
  const source = SessionManager.open(
529
- agent.session_file,
863
+ canonicalPath(agent.session_file),
530
864
  this.options.sessionDir,
531
865
  this.options.cwd,
532
866
  );
533
- const leafId = source.getLeafId();
534
- if (!leafId)
867
+ verifyChildSessionIdentity(source, agent, sourceRootSessionId);
868
+ const leafId = agent.session_leaf_id ?? source.getLeafId();
869
+ if (!leafId || !source.getEntry(leafId))
535
870
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`);
536
871
  const sessionFile = source.createBranchedSession(leafId);
537
872
  if (!sessionFile)
538
873
  throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`);
539
- 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,
540
887
  source_agent_id: agent.agent_id,
541
888
  source_session_id: agent.session_id,
542
889
  });
543
890
  if (!existsSync(sessionFile)) {
544
- const lines = [source.getHeader(), ...source.getEntries()]
891
+ const lines = [clone.getHeader(), ...clone.getEntries()]
545
892
  .filter((entry) => entry !== null)
546
893
  .map((entry) => JSON.stringify(entry))
547
894
  .join("\n");
@@ -550,24 +897,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
550
897
  if (!existsSync(sessionFile)) {
551
898
  throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`);
552
899
  }
553
- return { sessionFile, sessionId: source.getSessionId() };
554
- }
555
-
556
- async trashSessionFile(sessionFile: string): Promise<void> {
557
- const trashError = await new Promise<Error | undefined>((resolvePromise) => {
558
- const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
559
- execFile("trash", trashArguments, (error) => resolvePromise(error ?? undefined));
560
- });
561
- if (!trashError || !existsSync(sessionFile)) return;
562
-
563
- try {
564
- await unlink(sessionFile);
565
- } catch (error) {
566
- const unlinkError = error instanceof Error ? error.message : String(error);
567
- throw new Error(
568
- `Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
569
- );
570
- }
900
+ return {
901
+ sessionFile,
902
+ sessionId: clone.getSessionId(),
903
+ sessionLeafId: clone.getLeafId() ?? undefined,
904
+ };
571
905
  }
572
906
 
573
907
  private buildChildSystemPrompt(agent: PersistedAgent): string {
@@ -664,10 +998,12 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
664
998
  modelsPath: resolve(this.options.agentDir, "models.json"),
665
999
  });
666
1000
  const sessionManager = SessionManager.open(
667
- agent.session_file,
1001
+ canonicalPath(agent.session_file),
668
1002
  this.options.sessionDir,
669
1003
  this.options.cwd,
670
1004
  );
1005
+ verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
1006
+ if (agent.session_leaf_id) sessionManager.branch(agent.session_leaf_id);
671
1007
  const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id);
672
1008
  const allowedToolNames = [
673
1009
  ...agent.launch_contract.ordinary_tools,