@oai404iao/pi-subagent 0.3.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oai404iao/pi-subagent",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Durable, continuable subagents for Pi with spawn/fork providers and lifecycle controls.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,24 +27,24 @@
27
27
  },
28
28
  "pi": {
29
29
  "extensions": [
30
- "./src/index.ts"
30
+ "./index.ts"
31
31
  ]
32
32
  },
33
33
  "peerDependencies": {
34
- "@earendil-works/pi-agent-core": ">=0.84.2",
35
- "@earendil-works/pi-ai": ">=0.84.2",
36
- "@earendil-works/pi-coding-agent": ">=0.84.2",
37
- "@earendil-works/pi-tui": ">=0.84.2",
34
+ "@earendil-works/pi-agent-core": ">=0.85.1",
35
+ "@earendil-works/pi-ai": ">=0.85.1",
36
+ "@earendil-works/pi-coding-agent": ">=0.85.1",
37
+ "@earendil-works/pi-tui": ">=0.85.1",
38
38
  "typebox": "*"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@oai404iao/pi-codex-minimal-tools": "1.4.0"
41
+ "@oai404iao/pi-codex-minimal-tools": "2.0.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@earendil-works/pi-agent-core": "^0.84.2",
45
- "@earendil-works/pi-ai": "^0.84.2",
46
- "@earendil-works/pi-coding-agent": "^0.84.2",
47
- "@earendil-works/pi-tui": "^0.84.2",
44
+ "@earendil-works/pi-agent-core": "0.85.1",
45
+ "@earendil-works/pi-ai": "0.85.1",
46
+ "@earendil-works/pi-coding-agent": "0.85.1",
47
+ "@earendil-works/pi-tui": "0.85.1",
48
48
  "@types/node": "^26.2.0",
49
49
  "tsx": "^4.20.6",
50
50
  "typebox": "^1.1.24",
@@ -56,6 +56,7 @@
56
56
  "check": "npm run typecheck && npm test"
57
57
  },
58
58
  "files": [
59
+ "index.ts",
59
60
  "agents/",
60
61
  "LICENSES/",
61
62
  "provenance/",
@@ -87,5 +88,5 @@
87
88
  "optional": true
88
89
  }
89
90
  },
90
- "gitHead": "a93559be8f8b02739310c492b36270b13607fbf9"
91
+ "gitHead": "88cb6f56447d88b71232cd764ca5b29251341d23"
91
92
  }
@@ -0,0 +1,125 @@
1
+ import type {
2
+ DelegationDetails,
3
+ SubagentStopReason,
4
+ } from "./types.ts";
5
+
6
+ export type AgentLifecycleState = "open" | "closed";
7
+ export type AgentResidencyState = "resident" | "unloaded";
8
+
9
+ export type AgentTurnState =
10
+ | { state: "none" }
11
+ | { state: "queued"; turnId: string }
12
+ | { state: "running"; turnId: string }
13
+ | { state: "completed"; turnId: string }
14
+ | { state: "errored"; turnId: string }
15
+ | { state: "interrupted"; turnId: string };
16
+
17
+ export interface AgentControlState {
18
+ lifecycle: AgentLifecycleState;
19
+ residency: AgentResidencyState;
20
+ turn: AgentTurnState;
21
+ }
22
+
23
+ export function createAgentControlState(): AgentControlState {
24
+ return {
25
+ lifecycle: "open",
26
+ residency: "resident",
27
+ turn: { state: "none" },
28
+ };
29
+ }
30
+
31
+ export function queueAgentTurn(state: AgentControlState, turnId: string): void {
32
+ if (state.turn.state === "queued" || state.turn.state === "running") {
33
+ throw new Error(`agent turn ${state.turn.turnId} is already active`);
34
+ }
35
+ state.turn = { state: "queued", turnId };
36
+ }
37
+
38
+ export function startAgentTurn(state: AgentControlState, turnId: string): void {
39
+ if (state.turn.state !== "queued" || state.turn.turnId !== turnId) {
40
+ throw new Error(`agent turn ${turnId} was not queued`);
41
+ }
42
+ state.turn = { state: "running", turnId };
43
+ }
44
+
45
+ export function finishAgentTurn(
46
+ state: AgentControlState,
47
+ turnId: string,
48
+ stopReason: SubagentStopReason,
49
+ ): void {
50
+ if (
51
+ (state.turn.state !== "queued" &&
52
+ state.turn.state !== "running" &&
53
+ state.turn.state !== "interrupted") ||
54
+ state.turn.turnId !== turnId
55
+ ) {
56
+ throw new Error(`agent turn ${turnId} is not active`);
57
+ }
58
+ switch (stopReason) {
59
+ case "completed":
60
+ state.turn = { state: "completed", turnId };
61
+ return;
62
+ case "aborted":
63
+ state.turn = { state: "interrupted", turnId };
64
+ return;
65
+ case "error":
66
+ case "max-tokens":
67
+ state.turn = { state: "errored", turnId };
68
+ return;
69
+ }
70
+ }
71
+
72
+ export function interruptAgentTurn(state: AgentControlState): void {
73
+ if (state.turn.state === "queued" || state.turn.state === "running") {
74
+ state.turn = { state: "interrupted", turnId: state.turn.turnId };
75
+ }
76
+ }
77
+
78
+ export function setAgentResidency(
79
+ state: AgentControlState,
80
+ residency: AgentResidencyState,
81
+ ): void {
82
+ state.residency = residency;
83
+ }
84
+
85
+ export function closeAgent(state: AgentControlState): void {
86
+ state.lifecycle = "closed";
87
+ }
88
+
89
+ export function currentAgentTurnId(state: AgentControlState): string | undefined {
90
+ return state.turn.state === "none" ? undefined : state.turn.turnId;
91
+ }
92
+
93
+ export function delegationStatus(
94
+ state: AgentControlState,
95
+ hasOwnedChildren: boolean,
96
+ ): DelegationDetails["status"] {
97
+ switch (state.turn.state) {
98
+ case "none":
99
+ case "queued":
100
+ return "starting";
101
+ case "running":
102
+ return "running";
103
+ case "completed":
104
+ return hasOwnedChildren ? "waiting" : "completed";
105
+ case "errored":
106
+ case "interrupted":
107
+ return hasOwnedChildren ? "waiting" : "failed";
108
+ }
109
+ }
110
+
111
+ export function catalogStatus(
112
+ state: AgentControlState,
113
+ ): "running" | "idle" | "ready" {
114
+ if (state.residency === "unloaded") return "ready";
115
+ switch (state.turn.state) {
116
+ case "queued":
117
+ case "running":
118
+ return "running";
119
+ case "none":
120
+ case "completed":
121
+ case "errored":
122
+ case "interrupted":
123
+ return "idle";
124
+ }
125
+ }
package/src/agent-sync.ts CHANGED
@@ -30,8 +30,18 @@ interface AgentManifest {
30
30
  version: 1;
31
31
  packageVersion: string;
32
32
  files: Record<string, string>;
33
+ /**
34
+ * Bundled preset names the user deleted. Initialization never restores
35
+ * them, so deleting a preset stays effective across package versions.
36
+ */
37
+ retired: string[];
33
38
  }
34
39
 
40
+ type ManifestReadResult =
41
+ | { kind: "missing" }
42
+ | { kind: "valid"; manifest: AgentManifest }
43
+ | { kind: "corrupt"; diagnostic: string };
44
+
35
45
  interface BundledAgentFile {
36
46
  name: string;
37
47
  content: Buffer;
@@ -69,7 +79,14 @@ export interface AgentSyncResult {
69
79
  manifestPath: string;
70
80
  installed: string[];
71
81
  updated: string[];
82
+ /** Bundled presets retired upstream and removed from the user directory. */
72
83
  removed: string[];
84
+ /** Presets the user deleted; initialization keeps them absent. */
85
+ retired: string[];
86
+ /** Previously deleted presets that reappeared on disk as managed again. */
87
+ restored: string[];
88
+ /** True when this run recorded a new deletion or a recreation. */
89
+ retirementChanged: boolean;
73
90
  preserved: string[];
74
91
  backups: AgentBackup[];
75
92
  diagnostics: string[];
@@ -130,10 +147,27 @@ function parseManifest(value: unknown, manifestPath: string): AgentManifest {
130
147
  }
131
148
  files[name] = hash;
132
149
  }
150
+ const retired: string[] = [];
151
+ if (input.retired !== undefined) {
152
+ if (!Array.isArray(input.retired)) {
153
+ throw new Error("retired must be an array of bundled preset file names");
154
+ }
155
+ for (const name of input.retired) {
156
+ if (
157
+ typeof name !== "string" ||
158
+ basename(name) !== name ||
159
+ !name.endsWith(".md")
160
+ ) {
161
+ throw new Error(`retired contains an invalid entry for "${String(name)}"`);
162
+ }
163
+ if (!retired.includes(name)) retired.push(name);
164
+ }
165
+ }
133
166
  return {
134
167
  version: MANIFEST_VERSION,
135
168
  packageVersion: input.packageVersion.trim(),
136
169
  files,
170
+ retired,
137
171
  };
138
172
  }
139
173
 
@@ -182,53 +216,35 @@ function bundledAgentFiles(bundledDir: string): BundledAgentFile[] {
182
216
  return files;
183
217
  }
184
218
 
185
- function readPreviousManifest(manifestPath: string): AgentManifest | undefined {
186
- if (!existsSync(manifestPath)) return undefined;
219
+ function readPreviousManifest(manifestPath: string): ManifestReadResult {
220
+ if (!existsSync(manifestPath)) return { kind: "missing" };
221
+ let content: Buffer | undefined;
187
222
  try {
188
- return parseManifest(JSON.parse(readFileSync(manifestPath, "utf8")), manifestPath);
223
+ content = readFileSync(manifestPath);
224
+ const manifest = parseManifest(JSON.parse(content.toString("utf8")), manifestPath);
225
+ return { kind: "valid", manifest };
189
226
  } catch (error) {
190
- const corruptPath = `${manifestPath}.corrupt-${timestamp()}-${randomUUID().slice(0, 8)}`;
191
- copyFileSync(manifestPath, corruptPath);
192
- throw new Error(
193
- `${manifestPath}: invalid manifest; a copy was preserved at ${corruptPath}: ${
194
- error instanceof Error ? error.message : String(error)
195
- }`,
196
- );
197
- }
198
- }
199
-
200
- /**
201
- * Identify untouched files created by the old opt-out synchronizer without
202
- * changing the user filesystem. Direct bundled discovery can then use newer
203
- * package definitions while real user edits continue to override them.
204
- */
205
- export function unmodifiedManagedAgentNames(agentDir: string): Set<string> {
206
- const manifestPath = join(agentDir, STATE_DIR_NAME, MANIFEST_FILE_NAME);
207
- if (!existsSync(manifestPath)) return new Set();
208
-
209
- let manifest: AgentManifest;
210
- try {
211
- manifest = parseManifest(JSON.parse(readFileSync(manifestPath, "utf8")), manifestPath);
212
- } catch {
213
- // A malformed historical manifest must never cause a default read-only
214
- // session to hide user files or rewrite state.
215
- return new Set();
216
- }
217
-
218
- const unmodified = new Set<string>();
219
- const userAgentsDir = join(agentDir, "agents");
220
- for (const [name, expectedHash] of Object.entries(manifest.files)) {
227
+ const fingerprint =
228
+ typeof content === "undefined"
229
+ ? randomUUID().slice(0, 12)
230
+ : hash(content).slice(0, 12);
231
+ const corruptPath = `${manifestPath}.corrupt-${fingerprint}`;
232
+ let preservation = `a copy was preserved at ${corruptPath}`;
221
233
  try {
222
- const path = join(userAgentsDir, name);
223
- if (lstatSync(path).isFile() && hash(readFileSync(path)) === expectedHash) {
224
- unmodified.add(name);
225
- }
226
- } catch {
227
- // Missing, unreadable, or replaced paths are user-controlled and
228
- // therefore remain visible to discovery.
234
+ if (!existsSync(corruptPath)) copyFileSync(manifestPath, corruptPath);
235
+ } catch (backupError) {
236
+ preservation = `the corrupt file could not be copied: ${
237
+ backupError instanceof Error ? backupError.message : String(backupError)
238
+ }`;
229
239
  }
240
+ return {
241
+ kind: "corrupt",
242
+ diagnostic:
243
+ `${manifestPath}: invalid manifest; bundled-template initialization was skipped and user/project agents remain available; ${preservation}: ${
244
+ error instanceof Error ? error.message : String(error)
245
+ }`,
246
+ };
230
247
  }
231
- return unmodified;
232
248
  }
233
249
 
234
250
  function sleepSync(milliseconds: number): void {
@@ -445,71 +461,135 @@ function syncBundledAgentsLocked(
445
461
  manifestPath: string,
446
462
  ): AgentSyncResult {
447
463
  const diagnostics: string[] = [];
448
- const previous = readPreviousManifest(manifestPath);
449
- const packageChanged = previous !== undefined && previous.packageVersion !== packageVersion;
464
+ const manifestRead = readPreviousManifest(manifestPath);
465
+ if (manifestRead.kind === "corrupt") {
466
+ return {
467
+ packageVersion,
468
+ userAgentsDir,
469
+ manifestPath,
470
+ installed: [],
471
+ updated: [],
472
+ removed: [],
473
+ retired: [],
474
+ restored: [],
475
+ retirementChanged: false,
476
+ preserved: [],
477
+ backups: [],
478
+ diagnostics: [manifestRead.diagnostic],
479
+ };
480
+ }
481
+ const previous = manifestRead.kind === "valid" ? manifestRead.manifest : undefined;
482
+
450
483
  const files = bundledAgentFiles(options.bundledDir);
451
484
  const currentNames = new Set(files.map((file) => file.name));
485
+ const previousFiles = previous?.files ?? {};
486
+ const retiredNames = new Set<string>();
487
+ const restored: string[] = [];
488
+
489
+ // A preset that was managed before and is missing now was deleted by the
490
+ // user. Record that deletion at every startup so a later package version
491
+ // cannot resurrect it; an explicit recreation restores management.
492
+ for (const name of previous?.retired ?? []) {
493
+ if (currentNames.has(name) && existsSync(join(userAgentsDir, name))) {
494
+ restored.push(name);
495
+ }
496
+ }
497
+ for (const name of Object.keys(previousFiles)) {
498
+ if (!currentNames.has(name)) continue;
499
+ if (restored.includes(name)) continue;
500
+ if ((previous?.retired ?? []).includes(name)) {
501
+ retiredNames.add(name);
502
+ continue;
503
+ }
504
+ if (!existsSync(join(userAgentsDir, name))) retiredNames.add(name);
505
+ }
506
+ const recordedRetired = [...retiredNames].sort();
507
+ const retirementChanged =
508
+ restored.length > 0 ||
509
+ recordedRetired.join("\n") !== (previous?.retired ?? []).slice().sort().join("\n");
510
+
511
+ // The package copies are initialization templates, not a runtime fallback.
512
+ // Once this package version has been initialized, the user directory is
513
+ // authoritative: edits and deletions must survive every same-version start.
514
+ if (previous?.packageVersion === packageVersion && !retirementChanged) {
515
+ return {
516
+ packageVersion,
517
+ userAgentsDir,
518
+ manifestPath,
519
+ installed: [],
520
+ updated: [],
521
+ removed: [],
522
+ retired: recordedRetired,
523
+ restored: [],
524
+ retirementChanged: false,
525
+ preserved: [],
526
+ backups: [],
527
+ diagnostics,
528
+ };
529
+ }
530
+
452
531
  const preserved: string[] = [];
453
532
  const actions: PlannedAction[] = [];
533
+ const versionChanged = previous?.packageVersion !== packageVersion;
534
+
535
+ // Agent files change only on first install and package-version changes. A
536
+ // same-version startup that merely recorded a deletion or a recreation only
537
+ // rewrites the manifest below.
538
+ if (versionChanged) {
539
+ // Plan the complete operation before changing any user agent file.
540
+ for (const file of files) {
541
+ if (retiredNames.has(file.name)) continue;
542
+ const destination = join(userAgentsDir, file.name);
543
+ const destinationState = destinationKind(destination);
544
+ if (destinationState.kind === "missing") {
545
+ actions.push({
546
+ name: file.name,
547
+ kind: "install",
548
+ destination,
549
+ destinationKind: "missing",
550
+ content: file.content,
551
+ });
552
+ continue;
553
+ }
454
554
 
455
- // Plan the complete operation before changing any user agent file. A current
456
- // manifest/source pair means ordinary restarts do not even read user content.
457
- for (const file of files) {
458
- const destination = join(userAgentsDir, file.name);
459
- const destinationState = destinationKind(destination);
460
- if (destinationState.kind === "missing") {
555
+ if (
556
+ destinationState.kind === "file"
557
+ && sameRegularFile(destination, destinationState.size, file.content)
558
+ ) {
559
+ preserved.push(file.name);
560
+ continue;
561
+ }
461
562
  actions.push({
462
563
  name: file.name,
463
- kind: "install",
564
+ kind: "replace",
464
565
  destination,
465
- destinationKind: "missing",
566
+ destinationKind: destinationState.kind,
466
567
  content: file.content,
467
568
  });
468
- continue;
469
569
  }
470
570
 
471
- const previousHash = previous?.files[file.name];
472
- const bundledChanged = previousHash === undefined || previousHash !== file.hash;
473
- const refresh = previous === undefined || packageChanged || bundledChanged;
474
- if (!refresh) {
475
- preserved.push(file.name);
476
- continue;
477
- }
478
- if (
479
- destinationState.kind === "file" &&
480
- sameRegularFile(destination, destinationState.size, file.content)
481
- ) {
482
- preserved.push(file.name);
483
- continue;
571
+ // Retired bundled presets must not remain silently active. They are
572
+ // backed up like replacements, then removed; unrelated user-defined
573
+ // names remain.
574
+ for (const name of Object.keys(previousFiles).sort((left, right) => left.localeCompare(right))) {
575
+ if (currentNames.has(name)) continue;
576
+ const destination = join(userAgentsDir, name);
577
+ const destinationState = destinationKind(destination);
578
+ if (destinationState.kind === "missing") continue;
579
+ actions.push({
580
+ name,
581
+ kind: "remove",
582
+ destination,
583
+ destinationKind: destinationState.kind,
584
+ });
484
585
  }
485
- actions.push({
486
- name: file.name,
487
- kind: "replace",
488
- destination,
489
- destinationKind: destinationState.kind,
490
- content: file.content,
491
- });
492
- }
493
-
494
- // Retired bundled presets must not remain silently active. They are backed
495
- // up like replacements, then removed; unrelated user-defined names remain.
496
- for (const name of Object.keys(previous?.files ?? {}).sort((left, right) => left.localeCompare(right))) {
497
- if (currentNames.has(name)) continue;
498
- const destination = join(userAgentsDir, name);
499
- const destinationState = destinationKind(destination);
500
- if (destinationState.kind === "missing") continue;
501
- actions.push({
502
- name,
503
- kind: "remove",
504
- destination,
505
- destinationKind: destinationState.kind,
506
- });
507
586
  }
508
587
 
509
588
  const manifest: AgentManifest = {
510
589
  version: MANIFEST_VERSION,
511
590
  packageVersion,
512
591
  files: Object.fromEntries(files.map((file) => [file.name, file.hash])),
592
+ retired: recordedRetired,
513
593
  };
514
594
  const stagedPaths: string[] = [];
515
595
  const backups: AgentBackup[] = [];
@@ -576,6 +656,9 @@ function syncBundledAgentsLocked(
576
656
  installed: actions.filter((action) => action.kind === "install").map((action) => action.name),
577
657
  updated: actions.filter((action) => action.kind === "replace").map((action) => action.name),
578
658
  removed: actions.filter((action) => action.kind === "remove").map((action) => action.name),
659
+ retired: recordedRetired,
660
+ restored: [...restored].sort(),
661
+ retirementChanged,
579
662
  preserved,
580
663
  backups,
581
664
  diagnostics,
package/src/agents.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
4
  import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
@@ -13,10 +13,7 @@ export interface AgentDiscoveryOptions {
13
13
  cwd: string;
14
14
  scope: AgentScope;
15
15
  projectTrusted: boolean;
16
- bundledDir: string;
17
16
  agentDir?: string;
18
- includeBundled?: boolean;
19
- excludeUserAgentNames?: ReadonlySet<string>;
20
17
  }
21
18
 
22
19
  export interface AgentDiscoveryResult {
@@ -94,11 +91,7 @@ function loadAgentFile(filePath: string, source: AgentSource): AgentDefinition {
94
91
  };
95
92
  }
96
93
 
97
- function loadDirectory(
98
- dir: string,
99
- source: AgentSource,
100
- excludeNames?: ReadonlySet<string>,
101
- ): { agents: AgentDefinition[]; diagnostics: string[] } {
94
+ function loadDirectory(dir: string, source: AgentSource): { agents: AgentDefinition[]; diagnostics: string[] } {
102
95
  if (!isDirectory(dir)) return { agents: [], diagnostics: [] };
103
96
  const agents: AgentDefinition[] = [];
104
97
  const diagnostics: string[] = [];
@@ -114,7 +107,6 @@ function loadDirectory(
114
107
 
115
108
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
116
109
  if (!entry.name.endsWith(".md") || (!entry.isFile() && !entry.isSymbolicLink())) continue;
117
- if (excludeNames?.has(entry.name)) continue;
118
110
  const filePath = join(dir, entry.name);
119
111
  try {
120
112
  agents.push(loadAgentFile(filePath, source));
@@ -131,9 +123,6 @@ export function discoverAgents(options: AgentDiscoveryOptions): AgentDiscoveryRe
131
123
  ? findNearestProjectAgentsDir(options.cwd)
132
124
  : undefined;
133
125
  const sources: Array<{ dir: string; source: AgentSource }> = [];
134
- if (options.includeBundled !== false) {
135
- sources.push({ dir: options.bundledDir, source: "bundled" });
136
- }
137
126
  if (options.scope !== "project") {
138
127
  sources.push({ dir: join(options.agentDir ?? getAgentDir(), "agents"), source: "user" });
139
128
  }
@@ -148,11 +137,7 @@ export function discoverAgents(options: AgentDiscoveryOptions): AgentDiscoveryRe
148
137
 
149
138
  const byName = new Map<string, AgentDefinition>();
150
139
  for (const item of sources) {
151
- const loaded = loadDirectory(
152
- item.dir,
153
- item.source,
154
- item.source === "user" ? options.excludeUserAgentNames : undefined,
155
- );
140
+ const loaded = loadDirectory(item.dir, item.source);
156
141
  diagnostics.push(...loaded.diagnostics);
157
142
  for (const agent of loaded.agents) byName.set(agent.name, agent);
158
143
  }
@@ -168,7 +153,3 @@ export function formatAgentCatalog(agents: AgentDefinition[]): string {
168
153
  if (agents.length === 0) return "(no agents)";
169
154
  return agents.map((agent) => `${agent.name} (${agent.source}) — ${agent.description}`).join("\n");
170
155
  }
171
-
172
- export function hasBundledAgents(dir: string): boolean {
173
- return existsSync(dir) && isDirectory(dir);
174
- }
package/src/catalog.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  import { SessionManager } from "@earendil-works/pi-coding-agent";
2
2
  import { foldDescriptor } from "./descriptor.ts";
3
+ import {
4
+ foldCompletionMailbox,
5
+ unreadCompletionCounts,
6
+ } from "./completion-mailbox.ts";
7
+ import { foldOwnedMailbox } from "./mailbox.ts";
3
8
  import type { SessionView } from "./providers.ts";
4
9
  import type { CatalogDiagnostic, SubagentDescriptor } from "./types.ts";
5
10
 
6
11
  export interface PersistedDescriptor {
7
12
  agentId: string;
13
+ piSessionId: string;
8
14
  sessionFile: string;
9
15
  descriptor: SubagentDescriptor;
16
+ pendingMessages: number;
17
+ unreadUpdatesByChild: Map<string, number>;
10
18
  }
11
19
 
12
20
  export interface PersistedCatalog {
@@ -52,10 +60,49 @@ export async function readPersistedCatalog(session: SessionView): Promise<Persis
52
60
  });
53
61
  return;
54
62
  }
63
+ let pendingMessages = 0;
64
+ const mailbox = foldOwnedMailbox(manager.getEntries(), {
65
+ parentAgentId: folded.descriptor.parentAgentId,
66
+ agentId: folded.descriptor.agentId,
67
+ });
68
+ if (mailbox.kind === "corrupt") {
69
+ diagnostics.push({
70
+ kind: "diagnostic",
71
+ piSessionId: manager.getSessionId(),
72
+ reason: "corrupt",
73
+ sessionFile: info.path,
74
+ ...(headerParent ? { parentSessionFile: headerParent } : {}),
75
+ message: `corrupt subagent mailbox: ${mailbox.message}`,
76
+ });
77
+ return;
78
+ }
79
+ pendingMessages = mailbox.snapshot.pending.length;
80
+ let unreadUpdatesByChild = new Map<string, number>();
81
+ const completions = foldCompletionMailbox(
82
+ manager.getEntries(),
83
+ { parentAgentId: folded.descriptor.agentId },
84
+ );
85
+ if (completions.kind === "corrupt") {
86
+ diagnostics.push({
87
+ kind: "diagnostic",
88
+ piSessionId: manager.getSessionId(),
89
+ reason: "corrupt",
90
+ sessionFile: info.path,
91
+ ...(headerParent ? { parentSessionFile: headerParent } : {}),
92
+ message: `corrupt completion mailbox: ${completions.message}`,
93
+ });
94
+ } else {
95
+ unreadUpdatesByChild = unreadCompletionCounts(
96
+ completions.snapshot,
97
+ );
98
+ }
55
99
  descriptors.push({
56
100
  agentId: folded.descriptor.agentId,
101
+ piSessionId: manager.getSessionId(),
57
102
  sessionFile: info.path,
58
103
  descriptor: folded.descriptor,
104
+ pendingMessages,
105
+ unreadUpdatesByChild,
59
106
  });
60
107
  } else if (folded.kind === "corrupt") {
61
108
  const headerParent = manager.getHeader()?.parentSession;