@oai404iao/pi-subagent 0.2.0 → 0.4.0-alpha.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.
@@ -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
@@ -32,6 +32,11 @@ interface AgentManifest {
32
32
  files: Record<string, string>;
33
33
  }
34
34
 
35
+ type ManifestReadResult =
36
+ | { kind: "missing" }
37
+ | { kind: "valid"; manifest: AgentManifest }
38
+ | { kind: "corrupt"; diagnostic: string };
39
+
35
40
  interface BundledAgentFile {
36
41
  name: string;
37
42
  content: Buffer;
@@ -182,53 +187,35 @@ function bundledAgentFiles(bundledDir: string): BundledAgentFile[] {
182
187
  return files;
183
188
  }
184
189
 
185
- function readPreviousManifest(manifestPath: string): AgentManifest | undefined {
186
- if (!existsSync(manifestPath)) return undefined;
190
+ function readPreviousManifest(manifestPath: string): ManifestReadResult {
191
+ if (!existsSync(manifestPath)) return { kind: "missing" };
192
+ let content: Buffer | undefined;
187
193
  try {
188
- return parseManifest(JSON.parse(readFileSync(manifestPath, "utf8")), manifestPath);
194
+ content = readFileSync(manifestPath);
195
+ const manifest = parseManifest(JSON.parse(content.toString("utf8")), manifestPath);
196
+ return { kind: "valid", manifest };
189
197
  } 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)) {
198
+ const fingerprint =
199
+ typeof content === "undefined"
200
+ ? randomUUID().slice(0, 12)
201
+ : hash(content).slice(0, 12);
202
+ const corruptPath = `${manifestPath}.corrupt-${fingerprint}`;
203
+ let preservation = `a copy was preserved at ${corruptPath}`;
221
204
  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.
205
+ if (!existsSync(corruptPath)) copyFileSync(manifestPath, corruptPath);
206
+ } catch (backupError) {
207
+ preservation = `the corrupt file could not be copied: ${
208
+ backupError instanceof Error ? backupError.message : String(backupError)
209
+ }`;
229
210
  }
211
+ return {
212
+ kind: "corrupt",
213
+ diagnostic:
214
+ `${manifestPath}: invalid manifest; bundled-template initialization was skipped and user/project agents remain available; ${preservation}: ${
215
+ error instanceof Error ? error.message : String(error)
216
+ }`,
217
+ };
230
218
  }
231
- return unmodified;
232
219
  }
233
220
 
234
221
  function sleepSync(milliseconds: number): void {
@@ -445,15 +432,46 @@ function syncBundledAgentsLocked(
445
432
  manifestPath: string,
446
433
  ): AgentSyncResult {
447
434
  const diagnostics: string[] = [];
448
- const previous = readPreviousManifest(manifestPath);
449
- const packageChanged = previous !== undefined && previous.packageVersion !== packageVersion;
435
+ const manifestRead = readPreviousManifest(manifestPath);
436
+ if (manifestRead.kind === "corrupt") {
437
+ return {
438
+ packageVersion,
439
+ userAgentsDir,
440
+ manifestPath,
441
+ installed: [],
442
+ updated: [],
443
+ removed: [],
444
+ preserved: [],
445
+ backups: [],
446
+ diagnostics: [manifestRead.diagnostic],
447
+ };
448
+ }
449
+ const previous = manifestRead.kind === "valid" ? manifestRead.manifest : undefined;
450
+
451
+ // The package copies are initialization templates, not a runtime fallback.
452
+ // Once this package version has been initialized, the user directory is
453
+ // authoritative: edits and deletions must survive every same-version start.
454
+ if (previous?.packageVersion === packageVersion) {
455
+ return {
456
+ packageVersion,
457
+ userAgentsDir,
458
+ manifestPath,
459
+ installed: [],
460
+ updated: [],
461
+ removed: [],
462
+ preserved: [],
463
+ backups: [],
464
+ diagnostics,
465
+ };
466
+ }
467
+
450
468
  const files = bundledAgentFiles(options.bundledDir);
451
469
  const currentNames = new Set(files.map((file) => file.name));
452
470
  const preserved: string[] = [];
453
471
  const actions: PlannedAction[] = [];
454
472
 
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.
473
+ // Plan the complete first-install or version-change operation before
474
+ // changing any user agent file.
457
475
  for (const file of files) {
458
476
  const destination = join(userAgentsDir, file.name);
459
477
  const destinationState = destinationKind(destination);
@@ -468,13 +486,6 @@ function syncBundledAgentsLocked(
468
486
  continue;
469
487
  }
470
488
 
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
489
  if (
479
490
  destinationState.kind === "file" &&
480
491
  sameRegularFile(destination, destinationState.size, file.content)
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
- id: string;
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 {
@@ -44,7 +52,7 @@ export async function readPersistedCatalog(session: SessionView): Promise<Persis
44
52
  if (headerParent !== folded.descriptor.parentSessionFile) {
45
53
  diagnostics.push({
46
54
  kind: "diagnostic",
47
- id: manager.getSessionId(),
55
+ piSessionId: manager.getSessionId(),
48
56
  reason: "corrupt",
49
57
  sessionFile: info.path,
50
58
  ...(headerParent ? { parentSessionFile: headerParent } : {}),
@@ -52,16 +60,57 @@ export async function readPersistedCatalog(session: SessionView): Promise<Persis
52
60
  });
53
61
  return;
54
62
  }
63
+ let pendingMessages = 0;
64
+ if (folded.descriptor.runtime.backgroundProtocol === "mailbox-v2") {
65
+ const mailbox = foldOwnedMailbox(manager.getEntries(), {
66
+ parentAgentId: folded.descriptor.parentAgentId,
67
+ agentId: folded.descriptor.agentId,
68
+ });
69
+ if (mailbox.kind === "corrupt") {
70
+ diagnostics.push({
71
+ kind: "diagnostic",
72
+ piSessionId: manager.getSessionId(),
73
+ reason: "corrupt",
74
+ sessionFile: info.path,
75
+ ...(headerParent ? { parentSessionFile: headerParent } : {}),
76
+ message: `corrupt subagent mailbox: ${mailbox.message}`,
77
+ });
78
+ return;
79
+ }
80
+ pendingMessages = mailbox.snapshot.pending.length;
81
+ }
82
+ let unreadUpdatesByChild = new Map<string, number>();
83
+ const completions = foldCompletionMailbox(
84
+ manager.getEntries(),
85
+ { parentAgentId: folded.descriptor.agentId },
86
+ );
87
+ if (completions.kind === "corrupt") {
88
+ diagnostics.push({
89
+ kind: "diagnostic",
90
+ piSessionId: manager.getSessionId(),
91
+ reason: "corrupt",
92
+ sessionFile: info.path,
93
+ ...(headerParent ? { parentSessionFile: headerParent } : {}),
94
+ message: `corrupt completion mailbox: ${completions.message}`,
95
+ });
96
+ } else {
97
+ unreadUpdatesByChild = unreadCompletionCounts(
98
+ completions.snapshot,
99
+ );
100
+ }
55
101
  descriptors.push({
56
- id: manager.getSessionId(),
102
+ agentId: folded.descriptor.agentId,
103
+ piSessionId: manager.getSessionId(),
57
104
  sessionFile: info.path,
58
105
  descriptor: folded.descriptor,
106
+ pendingMessages,
107
+ unreadUpdatesByChild,
59
108
  });
60
109
  } else if (folded.kind === "corrupt") {
61
110
  const headerParent = manager.getHeader()?.parentSession;
62
111
  diagnostics.push({
63
112
  kind: "diagnostic",
64
- id: manager.getSessionId(),
113
+ piSessionId: manager.getSessionId(),
65
114
  reason: "corrupt",
66
115
  sessionFile: info.path,
67
116
  ...(headerParent ? { parentSessionFile: headerParent } : {}),
@@ -71,7 +120,7 @@ export async function readPersistedCatalog(session: SessionView): Promise<Persis
71
120
  } catch (error) {
72
121
  diagnostics.push({
73
122
  kind: "diagnostic",
74
- id: info.id,
123
+ piSessionId: info.id,
75
124
  reason: "unavailable",
76
125
  sessionFile: info.path,
77
126
  ...(info.parentSessionPath ? { parentSessionFile: info.parentSessionPath } : {}),
@@ -82,8 +131,11 @@ export async function readPersistedCatalog(session: SessionView): Promise<Persis
82
131
 
83
132
  descriptors.sort(
84
133
  (left, right) =>
85
- left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) || left.id.localeCompare(right.id),
134
+ left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) ||
135
+ left.agentId.localeCompare(right.agentId),
136
+ );
137
+ diagnostics.sort((left, right) =>
138
+ left.piSessionId.localeCompare(right.piSessionId),
86
139
  );
87
- diagnostics.sort((left, right) => left.id.localeCompare(right.id));
88
140
  return { descriptors, diagnostics };
89
141
  }