@hheei/pi-subagents 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.
@@ -0,0 +1,368 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { errorMessage } from "@hheei/pi-ext-core";
5
+ import { holdProcessLock, processLockHeld, processLockPath, } from "./process-lock.js";
6
+ import { BridgeError } from "./protocol.js";
7
+ import { claimCommand, claimParentCommands, deleteCommand, enqueueCommand, finishCommand, hasPendingCommand, openStateDb, readCommand, requeueAbandonedParentCommands, withWrite, } from "./state-db.js";
8
+ const DEFAULT_POLL_MS = 50;
9
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
10
+ export function createMailboxTransport(options) {
11
+ const db = openStateDb(options.filePath);
12
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
13
+ const seen = new Set();
14
+ const eventListeners = new Set();
15
+ const connectionListeners = new Set();
16
+ let closed = false;
17
+ let polling = false;
18
+ const emitConnection = (childId, connected) => {
19
+ for (const listener of [...connectionListeners]) {
20
+ try {
21
+ listener(childId, connected);
22
+ }
23
+ catch (error) {
24
+ options.diagnose?.(`connection listener failed: ${errorMessage(error)}`);
25
+ }
26
+ }
27
+ };
28
+ const lockHeld = (childId) => {
29
+ const lockPath = processLockPath(options.filePath, options.parentSessionId, childId);
30
+ if (lockPath === undefined)
31
+ return false;
32
+ try {
33
+ return processLockHeld(lockPath);
34
+ }
35
+ catch (error) {
36
+ options.diagnose?.(`could not probe the process lock for ${childId}: ${errorMessage(error)}`);
37
+ return undefined;
38
+ }
39
+ };
40
+ const noticeLocks = () => {
41
+ if (closed)
42
+ return;
43
+ const directory = join(dirname(options.filePath), "locks", options.parentSessionId);
44
+ let names = [];
45
+ try {
46
+ names = readdirSync(directory);
47
+ }
48
+ catch (error) {
49
+ const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
50
+ if (!missing) {
51
+ options.diagnose?.(`could not read process locks: ${errorMessage(error)}`);
52
+ return;
53
+ }
54
+ }
55
+ const heldNow = new Set();
56
+ for (const name of names) {
57
+ if (!name.endsWith(".lock"))
58
+ continue;
59
+ const childId = name.slice(0, -".lock".length);
60
+ const held = lockHeld(childId);
61
+ if (held === true || (held === undefined && seen.has(childId)))
62
+ heldNow.add(childId);
63
+ }
64
+ for (const childId of heldNow) {
65
+ if (seen.has(childId))
66
+ continue;
67
+ seen.add(childId);
68
+ emitConnection(childId, true);
69
+ }
70
+ for (const childId of [...seen]) {
71
+ if (heldNow.has(childId))
72
+ continue;
73
+ seen.delete(childId);
74
+ emitConnection(childId, false);
75
+ }
76
+ };
77
+ const takeParentCommands = async () => {
78
+ if (closed || polling)
79
+ return;
80
+ polling = true;
81
+ try {
82
+ noticeLocks();
83
+ // Empty is the common tick. A read sees it; claiming would take a write lock and fsync.
84
+ if (!hasPendingCommand(db, options.parentSessionId, "to_parent"))
85
+ return;
86
+ const claimed = withWrite(db, () => claimParentCommands(db, options.parentSessionId));
87
+ const eventIds = [];
88
+ const dropEvents = () => {
89
+ if (eventIds.length === 0)
90
+ return;
91
+ const ids = eventIds.splice(0);
92
+ withWrite(db, () => {
93
+ for (const id of ids)
94
+ deleteCommand(db, id);
95
+ });
96
+ };
97
+ for (const command of claimed) {
98
+ if (!seen.has(command.subagentId) && lockHeld(command.subagentId) === true) {
99
+ seen.add(command.subagentId);
100
+ emitConnection(command.subagentId, true);
101
+ }
102
+ if (command.operation === "event") {
103
+ let event = command.payload;
104
+ try {
105
+ event = JSON.parse(command.payload);
106
+ }
107
+ catch {
108
+ event = command.payload;
109
+ }
110
+ for (const listener of [...eventListeners]) {
111
+ try {
112
+ listener(command.subagentId, event);
113
+ }
114
+ catch (error) {
115
+ options.diagnose?.(`event listener failed: ${errorMessage(error)}`);
116
+ }
117
+ }
118
+ eventIds.push(command.id);
119
+ continue;
120
+ }
121
+ dropEvents();
122
+ try {
123
+ const payload = JSON.parse(command.payload);
124
+ const result = await options.handleRequest({
125
+ childId: command.subagentId,
126
+ operation: command.operation,
127
+ payload,
128
+ });
129
+ finishCommand(db, command.id, { status: "done", result: JSON.stringify(result ?? null) });
130
+ }
131
+ catch (error) {
132
+ const bridge = error instanceof BridgeError ? error : undefined;
133
+ finishCommand(db, command.id, {
134
+ status: "failed",
135
+ errorCode: bridge?.code ?? "request_failed",
136
+ error: bridge?.message ?? errorMessage(error),
137
+ });
138
+ }
139
+ }
140
+ dropEvents();
141
+ }
142
+ finally {
143
+ polling = false;
144
+ }
145
+ };
146
+ withWrite(db, () => {
147
+ requeueAbandonedParentCommands(db, options.parentSessionId);
148
+ });
149
+ const timer = setInterval(() => {
150
+ void takeParentCommands();
151
+ }, pollMs);
152
+ timer.unref?.();
153
+ return {
154
+ endpoint: options.filePath,
155
+ isConnected(childId) {
156
+ return lockHeld(childId) !== false;
157
+ },
158
+ disconnect(childId) {
159
+ if (lockHeld(childId) !== false)
160
+ return;
161
+ if (seen.delete(childId))
162
+ emitConnection(childId, false);
163
+ },
164
+ async request(childId, operation, payload, requestOptions) {
165
+ const id = randomUUID();
166
+ enqueueCommand(db, {
167
+ id,
168
+ parentSessionId: options.parentSessionId,
169
+ subagentId: childId,
170
+ direction: "to_child",
171
+ operation,
172
+ payload: JSON.stringify(payload ?? null),
173
+ createdAt: Date.now(),
174
+ });
175
+ return waitForCommand(db, id, requestOptions?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, pollMs, requestOptions?.signal);
176
+ },
177
+ onEvent(listener) {
178
+ eventListeners.add(listener);
179
+ return () => {
180
+ eventListeners.delete(listener);
181
+ };
182
+ },
183
+ onConnectionChange(listener) {
184
+ connectionListeners.add(listener);
185
+ return () => {
186
+ connectionListeners.delete(listener);
187
+ };
188
+ },
189
+ async waitUntilSeen(childId, waitOptions) {
190
+ const timeoutMs = waitOptions?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
191
+ const started = Date.now();
192
+ while (Date.now() - started < timeoutMs) {
193
+ if (waitOptions?.signal?.aborted)
194
+ return false;
195
+ if (lockHeld(childId) === true) {
196
+ if (!seen.has(childId)) {
197
+ seen.add(childId);
198
+ emitConnection(childId, true);
199
+ }
200
+ return true;
201
+ }
202
+ await delay(pollMs);
203
+ }
204
+ return false;
205
+ },
206
+ close() {
207
+ closed = true;
208
+ clearInterval(timer);
209
+ },
210
+ };
211
+ }
212
+ export function attachChildMailbox(options) {
213
+ let db;
214
+ let processLock;
215
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
216
+ let closed = false;
217
+ let started = false;
218
+ let working = false;
219
+ let timer;
220
+ const database = () => {
221
+ if (db === undefined)
222
+ throw new BridgeError("not_connected", "the state database is not open");
223
+ return db;
224
+ };
225
+ const tick = async () => {
226
+ if (closed || working || db === undefined)
227
+ return;
228
+ working = true;
229
+ const current = db;
230
+ try {
231
+ if (!hasPendingCommand(current, options.parentSessionId, "to_child", options.subagentId)) {
232
+ return;
233
+ }
234
+ const command = withWrite(current, () => claimCommand(current, options.parentSessionId, options.subagentId, "to_child"));
235
+ if (command === undefined)
236
+ return;
237
+ await answer(current, command, options.handleRequest, options.diagnose);
238
+ }
239
+ finally {
240
+ working = false;
241
+ }
242
+ };
243
+ return {
244
+ get connected() {
245
+ return started && !closed;
246
+ },
247
+ start() {
248
+ if (started || closed)
249
+ return;
250
+ try {
251
+ db = openStateDb(options.filePath);
252
+ const lockPath = processLockPath(options.filePath, options.parentSessionId, options.subagentId);
253
+ if (lockPath === undefined)
254
+ throw new Error("invalid process lock id");
255
+ processLock = holdProcessLock(lockPath);
256
+ }
257
+ catch (error) {
258
+ processLock?.release();
259
+ processLock = undefined;
260
+ db = undefined;
261
+ options.diagnose?.(`could not hold the child process lock: ${errorMessage(error)}`);
262
+ return;
263
+ }
264
+ started = true;
265
+ timer = setInterval(() => {
266
+ void tick();
267
+ }, pollMs);
268
+ timer.unref?.();
269
+ void tick();
270
+ },
271
+ report(operation, payload, signal) {
272
+ const current = database();
273
+ const id = randomUUID();
274
+ enqueueCommand(current, {
275
+ id,
276
+ parentSessionId: options.parentSessionId,
277
+ subagentId: options.subagentId,
278
+ direction: "to_parent",
279
+ operation,
280
+ payload: JSON.stringify(payload ?? null),
281
+ createdAt: Date.now(),
282
+ });
283
+ return waitForCommand(current, id, DEFAULT_REQUEST_TIMEOUT_MS, pollMs, signal);
284
+ },
285
+ sendEvent(event) {
286
+ if (db === undefined)
287
+ return;
288
+ const current = db;
289
+ enqueueCommand(current, {
290
+ id: randomUUID(),
291
+ parentSessionId: options.parentSessionId,
292
+ subagentId: options.subagentId,
293
+ direction: "to_parent",
294
+ operation: "event",
295
+ payload: JSON.stringify(event ?? null),
296
+ createdAt: Date.now(),
297
+ });
298
+ },
299
+ close() {
300
+ closed = true;
301
+ started = false;
302
+ if (timer !== undefined)
303
+ clearInterval(timer);
304
+ processLock?.release();
305
+ processLock = undefined;
306
+ },
307
+ };
308
+ }
309
+ async function answer(db, command, handleRequest, diagnose) {
310
+ try {
311
+ const payload = JSON.parse(command.payload);
312
+ const result = await handleRequest({ operation: command.operation, payload });
313
+ finishCommand(db, command.id, { status: "done", result: JSON.stringify(result ?? null) });
314
+ }
315
+ catch (error) {
316
+ const bridge = error instanceof BridgeError ? error : undefined;
317
+ diagnose?.(`child command ${command.operation} failed: ${errorMessage(error)}`);
318
+ finishCommand(db, command.id, {
319
+ status: "failed",
320
+ errorCode: bridge?.code ?? "request_failed",
321
+ error: bridge?.message ?? errorMessage(error),
322
+ });
323
+ }
324
+ }
325
+ function waitForCommand(db, id, timeoutMs, pollMs, signal) {
326
+ const started = Date.now();
327
+ return new Promise((resolve, reject) => {
328
+ const timer = setInterval(() => {
329
+ if (signal?.aborted) {
330
+ clearInterval(timer);
331
+ withWrite(db, () => {
332
+ deleteCommand(db, id);
333
+ });
334
+ reject(new BridgeError("aborted", "The mailbox request was cancelled"));
335
+ return;
336
+ }
337
+ const command = readCommand(db, id);
338
+ if (command === undefined)
339
+ return;
340
+ if (command.status === "done" ||
341
+ command.status === "failed" ||
342
+ Date.now() - started >= timeoutMs) {
343
+ clearInterval(timer);
344
+ withWrite(db, () => {
345
+ deleteCommand(db, command.id);
346
+ });
347
+ }
348
+ if (command.status === "done") {
349
+ resolve(command.result === null ? null : JSON.parse(command.result));
350
+ return;
351
+ }
352
+ if (command.status === "failed") {
353
+ reject(new BridgeError(command.errorCode ?? "request_failed", command.error ?? "The command failed"));
354
+ return;
355
+ }
356
+ if (Date.now() - started >= timeoutMs) {
357
+ reject(new BridgeError("timeout", `No answer for ${id} after ${timeoutMs}ms`));
358
+ }
359
+ }, pollMs);
360
+ timer.unref?.();
361
+ });
362
+ }
363
+ function delay(ms) {
364
+ return new Promise((resolve) => {
365
+ const timer = setTimeout(resolve, ms);
366
+ timer.unref?.();
367
+ });
368
+ }
package/dist/manager.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { type TaskRegistry } from "@hheei/pi-ext-core";
2
- import type { ChildRuntime } from "./child-process.js";
3
- import type { EffectiveLaunchConfig, OperationError, Presentation, PublicSubagent, SendMode, SpawnSubagentInput, SubagentRecord, SubagentState } from "./domain.js";
1
+ import { type ChildRuntime } from "./child-process.js";
2
+ import type { EffectiveLaunchConfig, OperationError, Presentation, PublicSubagent, RuntimeAttachment, SendMode, SpawnSubagentInput, SubagentRecord, SubagentState } from "./domain.js";
4
3
  import type { HostAttachment } from "./host-adapter.js";
4
+ import type { HostDirectory, LeaseReady } from "./lease.js";
5
5
  import { type BridgeOperation } from "./protocol.js";
6
6
  import type { SubagentRegistry } from "./registry.js";
7
7
  import type { LaunchOutcome, RuntimeTokenStore } from "./runtime.js";
@@ -25,7 +25,6 @@ export interface ParentChannelReport {
25
25
  }
26
26
  export interface ParentChannel {
27
27
  deliver(report: ParentChannelReport): Promise<void>;
28
- dismiss?(childId: string): void;
29
28
  }
30
29
  /**
31
30
  * The parent side of the bridge, as the manager needs it: one connection per child, requests the
@@ -45,7 +44,7 @@ export interface ChildBridgeTransport {
45
44
  }
46
45
  export interface ManagerDependencies {
47
46
  readonly parentSessionId: string;
48
- readonly registry: Pick<SubagentRegistry, "get" | "list" | "update">;
47
+ readonly registry: Pick<SubagentRegistry, "get" | "list" | "update" | "path">;
49
48
  readonly resolve: (input: SpawnSubagentInput) => Promise<EffectiveLaunchConfig>;
50
49
  readonly bootstrap: (input: {
51
50
  readonly parentSessionId: string;
@@ -64,6 +63,13 @@ export interface ManagerDependencies {
64
63
  readonly presentation?: PresentationDeps;
65
64
  /** Waits for an already-running runtime to reconnect; false when it does not. */
66
65
  readonly connect: (record: SubagentRecord) => Promise<boolean>;
66
+ /**
67
+ * Host listing used to restore this session's panels before the next turn. Absent in tests that
68
+ * still exercise the bridge-only recover path.
69
+ */
70
+ readonly directory?: HostDirectory & {
71
+ rebind?(attachment: RuntimeAttachment): HostAttachment | undefined;
72
+ };
67
73
  readonly deadlineMs?: number;
68
74
  readonly settledCloseTimeoutMs?: number;
69
75
  readonly idleTimeoutMs?: number;
@@ -101,13 +107,7 @@ export interface RecoveryResult {
101
107
  export declare function extractLastAssistantText(entries: readonly unknown[]): string | undefined;
102
108
  export declare class SubagentManager {
103
109
  #private;
104
- bindTaskRegistry(registry: TaskRegistry): () => void;
105
110
  constructor(deps: ManagerDependencies);
106
- /**
107
- * Observes the raw bridge events of every child. A Task producer needs the child's
108
- * `task_result` and settle events without also owning the bridge connection.
109
- */
110
- onChildEvent(listener: (childId: string, event: unknown) => void): () => void;
111
111
  /** Presentation-only: status and similar projections re-read list() after this. */
112
112
  onChange(listener: () => void): () => void;
113
113
  dispose(): void;
@@ -117,7 +117,7 @@ export declare class SubagentManager {
117
117
  * not when extensions are reloaded.
118
118
  */
119
119
  closeLocalConnections(): void;
120
- /** Reads a child's session entries; exposed for Task completion inspection. */
120
+ /** Reads a child's session entries; used by transcript inspection fallbacks. */
121
121
  getEntries(id: string): Promise<readonly unknown[] | undefined>;
122
122
  /**
123
123
  * Adopts the children that are still running. Nothing is launched here: a child that survived a
@@ -129,19 +129,17 @@ export declare class SubagentManager {
129
129
  send(id: string, rawMessage: string, mode?: SendMode, signal?: AbortSignal): Promise<OperationError | PublicSubagent>;
130
130
  get(id: string): Promise<PublicSubagent | OperationError>;
131
131
  list(): Promise<readonly PublicSubagent[]>;
132
- /** Child startup and runs count until Pi settles, including post-agent_end continuation. */
133
- get activeCount(): number;
134
132
  stop(id: string): Promise<OperationError | PublicSubagent>;
135
133
  contactParent(id: string, reason: string, message: string, knownRecord?: SubagentRecord): Promise<OperationError | {
136
134
  readonly delivered: true;
137
135
  }>;
138
- /**
139
- * Handles a request a child sent to its parent. The two child-initiated operations are the
140
- * child's report channel and its Task result, and both are answered here so the child learns
141
- * whether the parent accepted them.
142
- */
136
+ /** Answers a request a child sent to its parent, so the child learns whether the parent accepted it. */
143
137
  handleChildRequest(childId: string, operation: string, payload: unknown): Promise<unknown>;
144
138
  /** Applies a bridge event to this child's record and notifies the raw child-event listeners. */
145
139
  handleEvent(childId: string, event: unknown): void;
146
140
  handleConnectionChange(childId: string, connected: boolean): void;
141
+ holdAttachment(id: string, attachment: HostAttachment): void;
142
+ /** Starts restoration. `before_agent_start` waits on `ready` so the turn sees the same ledger. */
143
+ reconcile(): Promise<LeaseReady>;
144
+ ready(): Promise<LeaseReady>;
147
145
  }