@hheei/pi-subagents 0.2.4 → 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.
Files changed (61) hide show
  1. package/README.md +35 -20
  2. package/dist/agent-resolver.d.ts +9 -0
  3. package/dist/agent-resolver.js +278 -51
  4. package/dist/builtin-agents.js +50 -37
  5. package/dist/child-bridge.d.ts +0 -8
  6. package/dist/child-bridge.js +60 -43
  7. package/dist/child-control.d.ts +6 -4
  8. package/dist/child-control.js +251 -47
  9. package/dist/child-process.d.ts +2 -0
  10. package/dist/child-process.js +24 -12
  11. package/dist/commands.d.ts +1 -4
  12. package/dist/commands.js +1 -52
  13. package/dist/domain.d.ts +27 -12
  14. package/dist/domain.js +2 -1
  15. package/dist/extension.d.ts +7 -6
  16. package/dist/extension.js +96 -112
  17. package/dist/host-adapter.d.ts +19 -0
  18. package/dist/host-adapter.js +140 -1
  19. package/dist/info.d.ts +9 -0
  20. package/dist/info.js +80 -0
  21. package/dist/launch-spec.d.ts +0 -1
  22. package/dist/launch-spec.js +38 -18
  23. package/dist/lease.d.ts +83 -0
  24. package/dist/lease.js +239 -0
  25. package/dist/mailbox.d.ts +46 -0
  26. package/dist/mailbox.js +368 -0
  27. package/dist/manager.d.ts +20 -20
  28. package/dist/manager.js +421 -398
  29. package/dist/process-lock.d.ts +11 -0
  30. package/dist/process-lock.js +55 -0
  31. package/dist/protocol.d.ts +2 -2
  32. package/dist/protocol.js +1 -0
  33. package/dist/registry.d.ts +1 -1
  34. package/dist/registry.js +99 -82
  35. package/dist/rpc-events.d.ts +1 -1
  36. package/dist/rpc-events.js +2 -1
  37. package/dist/runtime.d.ts +8 -21
  38. package/dist/runtime.js +61 -83
  39. package/dist/session-bootstrap.d.ts +2 -1
  40. package/dist/session-bootstrap.js +22 -14
  41. package/dist/state-db.d.ts +52 -0
  42. package/dist/state-db.js +209 -0
  43. package/dist/state.d.ts +25 -0
  44. package/dist/state.js +166 -37
  45. package/dist/status.d.ts +14 -0
  46. package/dist/status.js +36 -0
  47. package/dist/tool-activation.d.ts +3 -4
  48. package/dist/tool-activation.js +13 -215
  49. package/dist/tool-presentation.d.ts +14 -0
  50. package/dist/tool-presentation.js +193 -0
  51. package/dist/tools.d.ts +6 -1
  52. package/dist/tools.js +134 -180
  53. package/package.json +6 -6
  54. package/dist/bridge-client.d.ts +0 -55
  55. package/dist/bridge-client.js +0 -453
  56. package/dist/bridge-server.d.ts +0 -72
  57. package/dist/bridge-server.js +0 -490
  58. package/dist/session-fork.d.ts +0 -16
  59. package/dist/session-fork.js +0 -67
  60. package/dist/widget.d.ts +0 -12
  61. package/dist/widget.js +0 -149
@@ -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,13 +1,13 @@
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";
8
8
  /**
9
9
  * How long an idle child keeps its runtime. A background child holds a whole Pi process and its
10
- * session lock, so an idle one is reclaimed after this window and a later `send_agent` starts it
10
+ * session lock, so an idle one is reclaimed after this window and a later `agent_send` starts it
11
11
  * again from the same session.
12
12
  */
13
13
  export declare const SUBAGENT_IDLE_TIMEOUT_MS = 15000;
@@ -44,7 +44,7 @@ export interface ChildBridgeTransport {
44
44
  }
45
45
  export interface ManagerDependencies {
46
46
  readonly parentSessionId: string;
47
- readonly registry: Pick<SubagentRegistry, "get" | "list" | "update">;
47
+ readonly registry: Pick<SubagentRegistry, "get" | "list" | "update" | "path">;
48
48
  readonly resolve: (input: SpawnSubagentInput) => Promise<EffectiveLaunchConfig>;
49
49
  readonly bootstrap: (input: {
50
50
  readonly parentSessionId: string;
@@ -63,6 +63,13 @@ export interface ManagerDependencies {
63
63
  readonly presentation?: PresentationDeps;
64
64
  /** Waits for an already-running runtime to reconnect; false when it does not. */
65
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
+ };
66
73
  readonly deadlineMs?: number;
67
74
  readonly settledCloseTimeoutMs?: number;
68
75
  readonly idleTimeoutMs?: number;
@@ -82,6 +89,7 @@ export interface PresentationDeps {
82
89
  }
83
90
  export interface SpawnResult {
84
91
  readonly child: PublicSubagent;
92
+ readonly path: string;
85
93
  /** Set when the child did not run where it would have by default. */
86
94
  readonly note?: string;
87
95
  }
@@ -99,14 +107,8 @@ export interface RecoveryResult {
99
107
  export declare function extractLastAssistantText(entries: readonly unknown[]): string | undefined;
100
108
  export declare class SubagentManager {
101
109
  #private;
102
- bindTaskRegistry(registry: TaskRegistry): () => void;
103
110
  constructor(deps: ManagerDependencies);
104
- /**
105
- * Observes the raw bridge events of every child. A Task producer needs the child's
106
- * `task_result` and settle events without also owning the bridge connection.
107
- */
108
- onChildEvent(listener: (childId: string, event: unknown) => void): () => void;
109
- /** Presentation-only: widget and similar projections re-read list() after this. */
111
+ /** Presentation-only: status and similar projections re-read list() after this. */
110
112
  onChange(listener: () => void): () => void;
111
113
  dispose(): void;
112
114
  /**
@@ -115,7 +117,7 @@ export declare class SubagentManager {
115
117
  * not when extensions are reloaded.
116
118
  */
117
119
  closeLocalConnections(): void;
118
- /** Reads a child's session entries; exposed for Task completion inspection. */
120
+ /** Reads a child's session entries; used by transcript inspection fallbacks. */
119
121
  getEntries(id: string): Promise<readonly unknown[] | undefined>;
120
122
  /**
121
123
  * Adopts the children that are still running. Nothing is launched here: a child that survived a
@@ -127,19 +129,17 @@ export declare class SubagentManager {
127
129
  send(id: string, rawMessage: string, mode?: SendMode, signal?: AbortSignal): Promise<OperationError | PublicSubagent>;
128
130
  get(id: string): Promise<PublicSubagent | OperationError>;
129
131
  list(): Promise<readonly PublicSubagent[]>;
130
- /** Child startup and runs count until Pi settles, including post-agent_end continuation. */
131
- get activeCount(): number;
132
132
  stop(id: string): Promise<OperationError | PublicSubagent>;
133
133
  contactParent(id: string, reason: string, message: string, knownRecord?: SubagentRecord): Promise<OperationError | {
134
134
  readonly delivered: true;
135
135
  }>;
136
- /**
137
- * Handles a request a child sent to its parent. The two child-initiated operations are the
138
- * child's report channel and its Task result, and both are answered here so the child learns
139
- * whether the parent accepted them.
140
- */
136
+ /** Answers a request a child sent to its parent, so the child learns whether the parent accepted it. */
141
137
  handleChildRequest(childId: string, operation: string, payload: unknown): Promise<unknown>;
142
138
  /** Applies a bridge event to this child's record and notifies the raw child-event listeners. */
143
139
  handleEvent(childId: string, event: unknown): void;
144
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>;
145
145
  }