@ian-pascoe/pi-minimal-subagents 0.6.5 → 0.7.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.
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { unlink } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -12,6 +12,7 @@ import {
12
12
  findCutPoint,
13
13
  generateSummaryWithUsage,
14
14
  ModelRuntime,
15
+ parseSessionEntries,
15
16
  SessionManager,
16
17
  SettingsManager,
17
18
  sessionEntryToContextMessages,
@@ -362,6 +363,9 @@ export function verifyChildSessionIdentity(
362
363
  `Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
363
364
  );
364
365
  }
366
+ if (agent.session_leaf_id && !sessionManager.getEntry(agent.session_leaf_id)) {
367
+ throw new Error(`Minimal subagents session identity mismatch: leaf for ${agent.agent_id}`);
368
+ }
365
369
  const identityBranch = sessionManager.getBranch(agent.session_leaf_id);
366
370
  const generation = findLatestForkGeneration(identityBranch);
367
371
  const identity =
@@ -401,9 +405,6 @@ export function verifyChildSessionIdentity(
401
405
  );
402
406
  }
403
407
  }
404
- if (agent.session_leaf_id && !sessionManager.getEntry(agent.session_leaf_id)) {
405
- throw new Error(`Minimal subagents session identity mismatch: leaf for ${agent.agent_id}`);
406
- }
407
408
  }
408
409
 
409
410
  /** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
@@ -532,6 +533,10 @@ export async function captureChildTurnOutcome(
532
533
  class PiChildAgentRuntime implements ChildAgentRuntime {
533
534
  private aborted = false;
534
535
  private readonly unsubscribe: () => void;
536
+ private transcriptLeafId: string | null | undefined;
537
+ private readonly transcriptEntries = new WeakMap<SessionEntry, AgentMessage[]>();
538
+ private transcriptMessages: AgentMessage[] = [];
539
+ private transcriptSources = new Set<AgentMessage>();
535
540
 
536
541
  constructor(
537
542
  private readonly session: AgentSession,
@@ -629,10 +634,46 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
629
634
  }
630
635
 
631
636
  snapshotActivityTranscript(): ChildAgentTranscriptSnapshot {
632
- const snapshot = selectChildAgentTranscript(
633
- this.session.messages,
634
- this.session.state.streamingMessage,
637
+ const manager = this.session.sessionManager;
638
+ const leafId = manager.getLeafId();
639
+ if (this.transcriptLeafId !== leafId) {
640
+ this.transcriptSources = new Set();
641
+ this.transcriptMessages = manager.getBranch().flatMap((entry) => {
642
+ const source = sessionEntryToContextMessages(entry);
643
+ for (const message of source) this.transcriptSources.add(message);
644
+ let messages = this.transcriptEntries.get(entry);
645
+ if (!messages) {
646
+ messages = selectChildAgentTranscript(source).messages;
647
+ this.transcriptEntries.set(entry, messages);
648
+ }
649
+ return messages;
650
+ });
651
+ this.transcriptLeafId = leafId;
652
+ }
653
+ const state = this.session.state;
654
+ // Native message_end finalizes agent state before async extension handlers persist it.
655
+ const pending = state.messages.filter(
656
+ (message) =>
657
+ (message.role === "user" ||
658
+ message.role === "assistant" ||
659
+ message.role === "toolResult") &&
660
+ !this.transcriptSources.has(message),
661
+ );
662
+ const streaming = state.streamingMessage;
663
+ const tail = selectChildAgentTranscript(
664
+ pending,
665
+ streaming && !this.transcriptSources.has(streaming) ? streaming : undefined,
635
666
  );
667
+ const snapshot: ChildAgentTranscriptSnapshot = {
668
+ messages: tail.messages.length
669
+ ? [...this.transcriptMessages, ...tail.messages]
670
+ : this.transcriptMessages,
671
+ streamingAssistantIndex:
672
+ tail.streamingAssistantIndex === undefined
673
+ ? undefined
674
+ : this.transcriptMessages.length + tail.streamingAssistantIndex,
675
+ toolDefinitions: [],
676
+ };
636
677
  const toolNames = new Set<string>();
637
678
  for (const message of snapshot.messages) {
638
679
  if (message.role !== "assistant") continue;
@@ -740,6 +781,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
740
781
  private readonly availableToolNames: Set<string>;
741
782
  private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
742
783
  private readonly sessionFileTrash: SessionFileTrashCapability;
784
+ private savedTranscript?: { key: string; snapshot: ChildAgentTranscriptSnapshot };
743
785
 
744
786
  constructor(private readonly options: PiAgentSessionFactoryOptions) {
745
787
  this.modelById = new Map(
@@ -763,6 +805,40 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
763
805
  });
764
806
  }
765
807
 
808
+ /** Read one verified saved Child Session Position without opening a writable runtime. */
809
+ readTranscript(agent: PersistedAgent): ChildAgentTranscriptSnapshot {
810
+ if (!agent.session_file || !agent.session_id || !agent.session_leaf_id) {
811
+ throw new Error(`Child Session Position is unavailable for ${agent.agent_id}.`);
812
+ }
813
+ const sessionFile = canonicalPath(agent.session_file);
814
+ const stat = statSync(sessionFile);
815
+ const key = JSON.stringify([
816
+ sessionFile,
817
+ agent.agent_id,
818
+ agent.parent_id,
819
+ agent.created_at,
820
+ agent.session_id,
821
+ agent.session_leaf_id,
822
+ stat.dev,
823
+ stat.ino,
824
+ stat.size,
825
+ stat.mtimeMs,
826
+ stat.ctimeMs,
827
+ ]);
828
+ if (this.savedTranscript?.key === key) return this.savedTranscript.snapshot;
829
+ const entries = parseSessionEntries(readFileSync(sessionFile, "utf8"));
830
+ if (entries[0]?.type !== "session")
831
+ throw new Error(`Invalid child session file: ${sessionFile}`);
832
+ // SessionManager.open can migrate/rewrite files; an in-memory reader cannot write them.
833
+ const manager = SessionManager.inMemory(this.options.cwd, undefined, entries);
834
+ verifyChildSessionIdentity(manager, agent, this.options.rootSessionId);
835
+ const snapshot = selectChildAgentTranscript(
836
+ manager.getBranch(agent.session_leaf_id).flatMap(sessionEntryToContextMessages),
837
+ );
838
+ this.savedTranscript = { key, snapshot };
839
+ return snapshot;
840
+ }
841
+
766
842
  resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]> {
767
843
  return this.findMissingDependencies(agent, false);
768
844
  }
@@ -4,8 +4,6 @@ import { randomUUID } from "node:crypto";
4
4
  import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import lockfile from "proper-lockfile";
7
- import { Type } from "typebox";
8
- import { Value } from "typebox/value";
9
7
 
10
8
  /** Identifies the standard Pi settings file changed by a Subagent Access command. */
11
9
  export type MinimalSubagentsSettingsScope = "global" | "project";
@@ -64,8 +62,10 @@ interface ExistingSettingsDocument {
64
62
  readonly mode: number;
65
63
  }
66
64
 
67
- const JsonValueSchema = Type.Unsafe<JsonValue>({});
68
- const SettingsJsonObjectSchema = Type.Record(Type.String(), JsonValueSchema);
65
+ function isSettingsJsonObject(value: JsonValue | undefined): value is SettingsJsonObject {
66
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- JSON.parse already established JSON data; distinguish object roots and settings blocks from primitives and arrays.
67
+ return value !== null && typeof value === "object" && !Array.isArray(value);
68
+ }
69
69
  const SETTINGS_LOCK_RETRY_DELAY_MS = 20;
70
70
  const SETTINGS_LOCK_RETRIES = 100;
71
71
  const NEW_SETTINGS_FILE_MODE = 0o600;
@@ -92,7 +92,8 @@ function parseSettingsDocument(
92
92
  scope: MinimalSubagentsSettingsScope,
93
93
  path: string,
94
94
  ): ParsedSettingsDocument {
95
- let parsed: unknown;
95
+ // JSON.parse is the provenance for this JSON type; the object shape is checked below.
96
+ let parsed: JsonValue;
96
97
  try {
97
98
  parsed = JSON.parse(stripUtf8Bom(content));
98
99
  } catch (cause) {
@@ -109,11 +110,11 @@ function parseSettingsDocument(
109
110
  };
110
111
  }
111
112
 
112
- if (!Value.Check(SettingsJsonObjectSchema, parsed)) {
113
+ if (!isSettingsJsonObject(parsed)) {
113
114
  return { ok: false, error: settingsContractError(scope, path, "expected an object root") };
114
115
  }
115
116
  const minimalSubagents = parsed.minimalSubagents;
116
- if (minimalSubagents !== undefined && !Value.Check(SettingsJsonObjectSchema, minimalSubagents)) {
117
+ if (minimalSubagents !== undefined && !isSettingsJsonObject(minimalSubagents)) {
117
118
  return {
118
119
  ok: false,
119
120
  error: settingsContractError(
@@ -131,7 +132,7 @@ function mutateMinimalSubagentsEnabled(
131
132
  enabled: boolean | undefined,
132
133
  ): void {
133
134
  const currentMinimalSubagents = settings.minimalSubagents;
134
- const minimalSubagents = Value.Check(SettingsJsonObjectSchema, currentMinimalSubagents)
135
+ const minimalSubagents = isSettingsJsonObject(currentMinimalSubagents)
135
136
  ? currentMinimalSubagents
136
137
  : {};
137
138