@narumitw/pi-subagents 0.13.1 → 0.14.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,164 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export type OrchestrationRecoveryTicket = {
4
+ generation: number;
5
+ revision: number;
6
+ nonce: string;
7
+ prompt: string;
8
+ };
9
+
10
+ type DelegatedAgent = {
11
+ live: boolean;
12
+ resultAvailable: boolean;
13
+ observedInTurn: boolean;
14
+ };
15
+
16
+ export const ORCHESTRATION_MARKER_PREFIX = "pi-subagent-orchestration:";
17
+
18
+ const RECOVERY_PROMPT = [
19
+ "Delegated subagents still need root coordination.",
20
+ "Do not yield permanently while current delegated work is unresolved.",
21
+ "Continue useful non-overlapping local work, or call subagent_wait when no useful local continuation remains.",
22
+ "Consume available subagent results and synthesize them before finishing.",
23
+ "Interrupt or close agents that are no longer needed; do not wait forever or spawn more agents unnecessarily.",
24
+ ].join(" ");
25
+
26
+ /** Ephemeral root-turn coordination state. Never persisted across sessions. */
27
+ export class RootOrchestrationState {
28
+ private generation = 0;
29
+ private revision = 0;
30
+ private turnOpen = false;
31
+ private agents = new Map<string, DelegatedAgent>();
32
+ private pending: OrchestrationRecoveryTicket | undefined;
33
+ private deliveredRevision: number | undefined;
34
+
35
+ reset(): void {
36
+ this.generation += 1;
37
+ this.revision = 0;
38
+ this.turnOpen = false;
39
+ this.agents.clear();
40
+ this.pending = undefined;
41
+ this.deliveredRevision = undefined;
42
+ }
43
+
44
+ beginTurn(): void {
45
+ this.turnOpen = true;
46
+ if (this.pending) this.deliveredRevision = this.pending.revision;
47
+ this.observeAvailable();
48
+ // New root work supersedes an intent that has not been delivered yet. The
49
+ // turn gets a chance to coordinate before agent_end evaluates it again.
50
+ this.pending = undefined;
51
+ }
52
+
53
+ spawn(agentId: string): void {
54
+ this.agents.set(agentId, {
55
+ live: true,
56
+ resultAvailable: false,
57
+ observedInTurn: false,
58
+ });
59
+ this.bumpRevision();
60
+ }
61
+
62
+ complete(agentId: string): void {
63
+ const existing = this.agents.get(agentId);
64
+ if (!existing) return;
65
+ this.agents.set(agentId, {
66
+ live: false,
67
+ resultAvailable: true,
68
+ observedInTurn: false,
69
+ });
70
+ this.bumpRevision();
71
+ if (!this.turnOpen) this.ensureRecovery();
72
+ }
73
+
74
+ resolve(agentId: string): void {
75
+ if (!this.agents.delete(agentId)) return;
76
+ this.bumpRevision();
77
+ }
78
+
79
+ observe(agentId: string): void {
80
+ const agent = this.agents.get(agentId);
81
+ if (agent && !agent.live && agent.resultAvailable) agent.observedInTurn = true;
82
+ }
83
+
84
+ observeAvailable(): void {
85
+ for (const agent of this.agents.values()) {
86
+ if (!agent.live && agent.resultAvailable) agent.observedInTurn = true;
87
+ }
88
+ }
89
+
90
+ endTurn(): OrchestrationRecoveryTicket | undefined {
91
+ if (this.turnOpen) {
92
+ for (const [id, agent] of this.agents) {
93
+ if (!agent.live && agent.resultAvailable && agent.observedInTurn) {
94
+ this.agents.delete(id);
95
+ }
96
+ }
97
+ this.turnOpen = false;
98
+ }
99
+ return this.ensureRecovery();
100
+ }
101
+
102
+ pendingTicket(): OrchestrationRecoveryTicket | undefined {
103
+ return this.pending;
104
+ }
105
+
106
+ isCurrent(ticket: OrchestrationRecoveryTicket): boolean {
107
+ return (
108
+ ticket.generation === this.generation &&
109
+ ticket.revision === this.revision &&
110
+ ticket.nonce === this.pending?.nonce &&
111
+ this.hasUnresolved()
112
+ );
113
+ }
114
+
115
+ markDelivered(ticket: OrchestrationRecoveryTicket): void {
116
+ if (!this.isCurrent(ticket)) return;
117
+ this.pending = undefined;
118
+ this.deliveredRevision = ticket.revision;
119
+ }
120
+
121
+ supersedePending(): OrchestrationRecoveryTicket | undefined {
122
+ const ticket = this.pending;
123
+ if (!ticket) return undefined;
124
+ this.pending = undefined;
125
+ this.deliveredRevision = ticket.revision;
126
+ return ticket;
127
+ }
128
+
129
+ hasUnresolved(): boolean {
130
+ return this.agents.size > 0;
131
+ }
132
+
133
+ liveAgentIds(): string[] {
134
+ return [...this.agents]
135
+ .filter(([, agent]) => agent.live)
136
+ .map(([id]) => id)
137
+ .sort();
138
+ }
139
+
140
+ private ensureRecovery(): OrchestrationRecoveryTicket | undefined {
141
+ if (!this.hasUnresolved()) {
142
+ this.pending = undefined;
143
+ return undefined;
144
+ }
145
+ if (this.deliveredRevision === this.revision) return undefined;
146
+ if (this.pending?.revision === this.revision && this.pending.generation === this.generation) {
147
+ return this.pending;
148
+ }
149
+ const nonce = randomUUID();
150
+ this.pending = {
151
+ generation: this.generation,
152
+ revision: this.revision,
153
+ nonce,
154
+ prompt: `${RECOVERY_PROMPT}\n<!-- ${ORCHESTRATION_MARKER_PREFIX}${nonce} -->`,
155
+ };
156
+ return this.pending;
157
+ }
158
+
159
+ private bumpRevision(): void {
160
+ this.revision += 1;
161
+ this.pending = undefined;
162
+ this.deliveredRevision = undefined;
163
+ }
164
+ }
@@ -0,0 +1,207 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
+ import { redactPrivateText } from "./context.js";
6
+ import type { ManagedAgent } from "./registry.js";
7
+
8
+ const STATE_VERSION = 2;
9
+ const MAX_STATE_BYTES = 1024 * 1024;
10
+
11
+ interface StoredState {
12
+ version: 2;
13
+ updatedAt: number;
14
+ agents: ManagedAgent[];
15
+ }
16
+
17
+ export interface PersistenceOptions {
18
+ retentionDays?: number;
19
+ maxStoredAgents?: number;
20
+ stateDir?: string;
21
+ }
22
+
23
+ export class AgentPersistence {
24
+ readonly filePath: string;
25
+ private readonly retentionMs: number;
26
+ private readonly maxStoredAgents: number;
27
+
28
+ constructor(owner: string, options: PersistenceOptions = {}) {
29
+ const retentionDays = options.retentionDays ?? 30;
30
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0) {
31
+ throw new Error("Subagent retentionDays must be a positive finite number");
32
+ }
33
+ const retentionMs = retentionDays * 24 * 60 * 60 * 1000;
34
+ if (!Number.isFinite(retentionMs)) {
35
+ throw new Error("Subagent retentionDays is too large");
36
+ }
37
+ const maxStoredAgents = options.maxStoredAgents ?? 50;
38
+ if (!Number.isSafeInteger(maxStoredAgents) || maxStoredAgents < 1) {
39
+ throw new Error("Subagent maxStoredAgents must be a positive safe integer");
40
+ }
41
+ const safeOwner = createHash("sha256").update(owner).digest("hex").slice(0, 24);
42
+ const stateDir = options.stateDir ?? path.join(getAgentDir(), "pi-subagents-state");
43
+ this.filePath = path.join(stateDir, `${safeOwner}.json`);
44
+ this.retentionMs = retentionMs;
45
+ this.maxStoredAgents = maxStoredAgents;
46
+ }
47
+
48
+ load(): ManagedAgent[] {
49
+ if (!fs.existsSync(this.filePath)) return [];
50
+ try {
51
+ const stat = fs.statSync(this.filePath);
52
+ if (stat.size > MAX_STATE_BYTES) throw new Error("state exceeds size limit");
53
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as unknown;
54
+ if (!isStoredState(parsed)) throw new Error("unsupported or malformed state");
55
+ const cutoff = Date.now() - this.retentionMs;
56
+ return parsed.agents
57
+ .filter((agent) => agent.updatedAt >= cutoff && agent.state !== "closed")
58
+ .slice(-this.maxStoredAgents)
59
+ .map(sanitizeAgent);
60
+ } catch {
61
+ this.quarantine();
62
+ return [];
63
+ }
64
+ }
65
+
66
+ async save(agents: readonly ManagedAgent[]): Promise<void> {
67
+ const cutoff = Date.now() - this.retentionMs;
68
+ const eligible = agents.filter(
69
+ (agent) => agent.state !== "closed" && agent.updatedAt >= cutoff,
70
+ );
71
+ const records = selectAgentsForPersistence(eligible, this.maxStoredAgents).map(sanitizeAgent);
72
+ const state: StoredState = { version: STATE_VERSION, updatedAt: Date.now(), agents: records };
73
+ let content = `${JSON.stringify(state, null, "\t")}\n`;
74
+ while (Buffer.byteLength(content, "utf8") > MAX_STATE_BYTES && state.agents.length > 0) {
75
+ const oldestRootId = state.agents[0].rootId;
76
+ state.agents = state.agents.filter((agent) => agent.rootId !== oldestRootId);
77
+ content = `${JSON.stringify(state, null, "\t")}\n`;
78
+ }
79
+ await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true });
80
+ await withFileMutationQueue(this.filePath, async () => {
81
+ const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
82
+ await fs.promises.writeFile(tempPath, content, { encoding: "utf8", mode: 0o600 });
83
+ await fs.promises.rename(tempPath, this.filePath);
84
+ });
85
+ }
86
+
87
+ async delete(): Promise<void> {
88
+ await withFileMutationQueue(this.filePath, async () => {
89
+ await fs.promises.rm(this.filePath, { force: true });
90
+ });
91
+ }
92
+
93
+ private quarantine(): void {
94
+ try {
95
+ fs.renameSync(this.filePath, `${this.filePath}.invalid-${Date.now()}`);
96
+ } catch {
97
+ // A concurrent process may already have moved or removed it.
98
+ }
99
+ }
100
+ }
101
+
102
+ function selectAgentsForPersistence(
103
+ agents: readonly ManagedAgent[],
104
+ maxAgents: number,
105
+ ): ManagedAgent[] {
106
+ const byId = new Map(agents.map((agent) => [agent.id, agent]));
107
+ const selected = new Map<string, ManagedAgent>();
108
+ const newestFirst = [...agents].sort((left, right) => right.updatedAt - left.updatedAt);
109
+ for (const agent of newestFirst) {
110
+ const chain: ManagedAgent[] = [];
111
+ let current: ManagedAgent | undefined = agent;
112
+ const seen = new Set<string>();
113
+ while (current && !seen.has(current.id)) {
114
+ seen.add(current.id);
115
+ chain.unshift(current);
116
+ current = current.parentId ? byId.get(current.parentId) : undefined;
117
+ }
118
+ if (current || (agent.parentId && chain[0].parentId)) continue;
119
+ const missing = chain.filter((candidate) => !selected.has(candidate.id));
120
+ if (selected.size + missing.length > maxAgents) continue;
121
+ for (const candidate of missing) selected.set(candidate.id, candidate);
122
+ }
123
+ return agents.filter((agent) => selected.has(agent.id));
124
+ }
125
+
126
+ function sanitizeAgent(agent: ManagedAgent): ManagedAgent {
127
+ return {
128
+ ...agent,
129
+ rootId: agent.rootId ?? agent.id,
130
+ depth: agent.depth ?? 0,
131
+ children: [...(agent.children ?? [])],
132
+ mailbox: (agent.mailbox ?? []).map((message) => ({
133
+ ...message,
134
+ recipientId: agent.id,
135
+ content: redactPrivateText(message.content),
136
+ })),
137
+ state: "idle",
138
+ currentTask: undefined,
139
+ currentMailboxMessageIds: undefined,
140
+ context: agent.context ? redactPrivateText(agent.context) : undefined,
141
+ error: agent.error ? redactPrivateText(agent.error) : undefined,
142
+ history: agent.history.map((turn) => ({
143
+ ...turn,
144
+ task: redactPrivateText(turn.task),
145
+ output: redactPrivateText(turn.output),
146
+ })),
147
+ };
148
+ }
149
+
150
+ function isStoredState(value: unknown): value is StoredState {
151
+ if (!value || typeof value !== "object") return false;
152
+ const state = value as { version?: unknown; agents?: unknown };
153
+ if ((state.version !== 1 && state.version !== STATE_VERSION) || !Array.isArray(state.agents)) {
154
+ return false;
155
+ }
156
+ return state.agents.every((agent) => {
157
+ if (!agent || typeof agent !== "object") return false;
158
+ const record = agent as Partial<ManagedAgent>;
159
+ return (
160
+ typeof record.id === "string" &&
161
+ typeof record.agent === "string" &&
162
+ typeof record.cwd === "string" &&
163
+ typeof record.createdAt === "number" &&
164
+ Number.isFinite(record.createdAt) &&
165
+ typeof record.updatedAt === "number" &&
166
+ Number.isFinite(record.updatedAt) &&
167
+ (record.parentId === undefined || typeof record.parentId === "string") &&
168
+ (record.children === undefined ||
169
+ (Array.isArray(record.children) && record.children.every((id) => typeof id === "string"))) &&
170
+ Array.isArray(record.history) &&
171
+ record.history.every(isAgentTurn) &&
172
+ (record.mailbox === undefined ||
173
+ (Array.isArray(record.mailbox) && record.mailbox.every(isMailboxMessage)))
174
+ );
175
+ });
176
+ }
177
+
178
+ function isAgentTurn(value: unknown): boolean {
179
+ if (!value || typeof value !== "object") return false;
180
+ const turn = value as Record<string, unknown>;
181
+ return (
182
+ typeof turn.task === "string" &&
183
+ typeof turn.output === "string" &&
184
+ typeof turn.startedAt === "number" &&
185
+ Number.isFinite(turn.startedAt) &&
186
+ typeof turn.completedAt === "number" &&
187
+ Number.isFinite(turn.completedAt) &&
188
+ typeof turn.exitCode === "number" &&
189
+ Number.isFinite(turn.exitCode)
190
+ );
191
+ }
192
+
193
+ function isMailboxMessage(value: unknown): boolean {
194
+ if (!value || typeof value !== "object") return false;
195
+ const message = value as Record<string, unknown>;
196
+ return (
197
+ typeof message.id === "string" &&
198
+ typeof message.senderId === "string" &&
199
+ typeof message.recipientId === "string" &&
200
+ typeof message.content === "string" &&
201
+ typeof message.createdAt === "number" &&
202
+ Number.isFinite(message.createdAt) &&
203
+ (message.readAt === undefined ||
204
+ (typeof message.readAt === "number" && Number.isFinite(message.readAt))) &&
205
+ (message.deduplicationKey === undefined || typeof message.deduplicationKey === "string")
206
+ );
207
+ }
@@ -0,0 +1,76 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+
3
+ const DEFAULT_MAX_LINE_BYTES = 256 * 1024;
4
+
5
+ export interface JsonLineDecoderOptions {
6
+ maxLineBytes?: number;
7
+ onValue: (value: unknown) => void;
8
+ onMalformed?: (line: string) => void;
9
+ onOversized?: (bytes: number) => void;
10
+ }
11
+
12
+ /** Bounded newline-delimited JSON decoder for child Pi event streams. */
13
+ export class JsonLineDecoder {
14
+ private buffer = "";
15
+ private droppingOversizedLine = false;
16
+ private readonly maxLineBytes: number;
17
+ private readonly decoder = new StringDecoder("utf8");
18
+
19
+ constructor(private readonly options: JsonLineDecoderOptions) {
20
+ const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
21
+ if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 1) {
22
+ throw new Error("JSON line limit must be a positive safe integer");
23
+ }
24
+ this.maxLineBytes = maxLineBytes;
25
+ }
26
+
27
+ push(chunk: string | Buffer): void {
28
+ this.buffer += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
29
+ this.drain(false);
30
+ }
31
+
32
+ finish(): void {
33
+ this.buffer += this.decoder.end();
34
+ this.drain(true);
35
+ this.buffer = "";
36
+ this.droppingOversizedLine = false;
37
+ }
38
+
39
+ private drain(flush: boolean): void {
40
+ while (true) {
41
+ const newline = this.buffer.indexOf("\n");
42
+ if (newline < 0) break;
43
+ const line = this.buffer.slice(0, newline).replace(/\r$/, "");
44
+ this.buffer = this.buffer.slice(newline + 1);
45
+ if (this.droppingOversizedLine) {
46
+ this.droppingOversizedLine = false;
47
+ continue;
48
+ }
49
+ this.processLine(line);
50
+ }
51
+
52
+ if (!flush && Buffer.byteLength(this.buffer, "utf8") > this.maxLineBytes) {
53
+ this.options.onOversized?.(Buffer.byteLength(this.buffer, "utf8"));
54
+ this.buffer = "";
55
+ this.droppingOversizedLine = true;
56
+ }
57
+
58
+ if (flush && this.buffer.length > 0 && !this.droppingOversizedLine) {
59
+ this.processLine(this.buffer.replace(/\r$/, ""));
60
+ }
61
+ }
62
+
63
+ private processLine(line: string): void {
64
+ if (!line.trim()) return;
65
+ const bytes = Buffer.byteLength(line, "utf8");
66
+ if (bytes > this.maxLineBytes) {
67
+ this.options.onOversized?.(bytes);
68
+ return;
69
+ }
70
+ try {
71
+ this.options.onValue(JSON.parse(line));
72
+ } catch {
73
+ this.options.onMalformed?.(line);
74
+ }
75
+ }
76
+ }