@hheei/pi-subagents 0.1.2

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/agent-resolver.d.ts +37 -0
  4. package/dist/agent-resolver.js +232 -0
  5. package/dist/child-bridge.d.ts +41 -0
  6. package/dist/child-bridge.js +214 -0
  7. package/dist/child-nudge.d.ts +24 -0
  8. package/dist/child-nudge.js +102 -0
  9. package/dist/commands.d.ts +6 -0
  10. package/dist/commands.js +155 -0
  11. package/dist/connector.d.ts +53 -0
  12. package/dist/connector.js +283 -0
  13. package/dist/diagnostics.d.ts +10 -0
  14. package/dist/diagnostics.js +20 -0
  15. package/dist/domain.d.ts +192 -0
  16. package/dist/domain.js +63 -0
  17. package/dist/extension.d.ts +2 -0
  18. package/dist/extension.js +145 -0
  19. package/dist/host-adapter.d.ts +76 -0
  20. package/dist/host-adapter.js +323 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/json-lines.d.ts +14 -0
  24. package/dist/json-lines.js +94 -0
  25. package/dist/launch-spec.d.ts +47 -0
  26. package/dist/launch-spec.js +149 -0
  27. package/dist/manager.d.ts +101 -0
  28. package/dist/manager.js +802 -0
  29. package/dist/pause-gate.d.ts +14 -0
  30. package/dist/pause-gate.js +64 -0
  31. package/dist/protocol.d.ts +141 -0
  32. package/dist/protocol.js +168 -0
  33. package/dist/registry.d.ts +41 -0
  34. package/dist/registry.js +575 -0
  35. package/dist/rpc-adapter.d.ts +40 -0
  36. package/dist/rpc-adapter.js +194 -0
  37. package/dist/rpc-events.d.ts +8 -0
  38. package/dist/rpc-events.js +29 -0
  39. package/dist/runner-entry.d.ts +24 -0
  40. package/dist/runner-entry.js +166 -0
  41. package/dist/runner.d.ts +52 -0
  42. package/dist/runner.js +930 -0
  43. package/dist/runtime.d.ts +22 -0
  44. package/dist/runtime.js +253 -0
  45. package/dist/session-bootstrap.d.ts +69 -0
  46. package/dist/session-bootstrap.js +195 -0
  47. package/dist/state.d.ts +15 -0
  48. package/dist/state.js +168 -0
  49. package/dist/tools.d.ts +9 -0
  50. package/dist/tools.js +158 -0
  51. package/dist/widget.d.ts +16 -0
  52. package/dist/widget.js +158 -0
  53. package/package.json +53 -0
@@ -0,0 +1,575 @@
1
+ import { join } from "node:path";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { readJsonSettingsRoot, updateJsonSettingsRoot } from "@hheei/pi-ext-core";
4
+ import { isExecutionMode, isLaunchValueSource, isPersistenceState, isRecord, isSessionId, isSubagentIntent, isSubagentState, isThinkingLevel, REGISTRY_VERSION, } from "./domain.js";
5
+ export class SubagentRegistryError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.name = "SubagentRegistryError";
10
+ this.code = code;
11
+ }
12
+ }
13
+ export function defaultRegistryPath(parentSessionId) {
14
+ return join(getAgentDir(), "pi-subagents", "registry", `${parentSessionId}.json`);
15
+ }
16
+ const ROOT_FIELDS = {
17
+ version: true,
18
+ parentSessionId: true,
19
+ updatedAt: true,
20
+ records: true,
21
+ };
22
+ const RECORD_FIELDS = {
23
+ subagentId: true,
24
+ parentSessionId: true,
25
+ revision: true,
26
+ createdAt: true,
27
+ updatedAt: true,
28
+ sessionId: true,
29
+ sessionPath: true,
30
+ cwd: true,
31
+ initialTask: true,
32
+ intent: true,
33
+ state: true,
34
+ mode: true,
35
+ persistence: true,
36
+ launchConfig: true,
37
+ runtime: true,
38
+ latestSummary: true,
39
+ usage: true,
40
+ interrupted: true,
41
+ unacknowledgedInput: true,
42
+ claim: true,
43
+ };
44
+ const LAUNCH_CONFIG_FIELDS = {
45
+ subagentId: true,
46
+ invocation: true,
47
+ cwd: true,
48
+ sessionId: true,
49
+ sessionDir: true,
50
+ sessionPath: true,
51
+ agent: true,
52
+ model: true,
53
+ thinking: true,
54
+ tools: true,
55
+ excludeTools: true,
56
+ extensions: true,
57
+ skills: true,
58
+ prompt: true,
59
+ bridgeExtensionPath: true,
60
+ interactive: true,
61
+ };
62
+ const AGENT_FIELDS = {
63
+ name: true,
64
+ displayName: true,
65
+ description: true,
66
+ hidden: true,
67
+ sourcePath: true,
68
+ instructions: true,
69
+ };
70
+ const INVOCATION_FIELDS = { command: true, args: true };
71
+ const MODEL_FIELDS = { provider: true, id: true, source: true };
72
+ const THINKING_FIELDS = { level: true, source: true };
73
+ const SELECTION_FIELDS = { discovery: true, paths: true };
74
+ const RUNTIME_FIELDS = { runtimeIdentity: true, endpoint: true, pid: true };
75
+ const CLAIM_FIELDS = {
76
+ claimId: true,
77
+ kind: true,
78
+ holderPid: true,
79
+ runtimeIdentity: true,
80
+ endpoint: true,
81
+ controllerTokenHash: true,
82
+ runnerPid: true,
83
+ };
84
+ const USAGE_FIELDS = {
85
+ inputTokens: true,
86
+ outputTokens: true,
87
+ cacheReadTokens: true,
88
+ cacheWriteTokens: true,
89
+ costUsd: true,
90
+ turns: true,
91
+ };
92
+ function corrupt(path, message) {
93
+ return new SubagentRegistryError("corrupt_registry", `Invalid registry ${path}: ${message}`);
94
+ }
95
+ function invalid(path, message) {
96
+ return new SubagentRegistryError("invalid_record", `Invalid subagent record in ${path}: ${message}`);
97
+ }
98
+ function expectKeys(value, allowed, label, path) {
99
+ for (const key of Object.keys(value)) {
100
+ if (allowed[key] !== true)
101
+ throw invalid(path, `${label} has unsupported field ${key}`);
102
+ }
103
+ }
104
+ function expectString(value, label, path) {
105
+ if (typeof value !== "string" || value === "") {
106
+ throw invalid(path, `${label} must be a non-empty string`);
107
+ }
108
+ return value;
109
+ }
110
+ function expectOptionalString(value, label, path) {
111
+ if (value === undefined)
112
+ return undefined;
113
+ return expectString(value, label, path);
114
+ }
115
+ function expectOptionalBoolean(value, label, path, fallback) {
116
+ if (value === undefined)
117
+ return fallback;
118
+ if (typeof value !== "boolean")
119
+ throw invalid(path, `${label} must be boolean`);
120
+ return value;
121
+ }
122
+ function expectIsoDate(value, label, path) {
123
+ const text = expectString(value, label, path);
124
+ if (Number.isNaN(Date.parse(text)))
125
+ throw invalid(path, `${label} must be an ISO timestamp`);
126
+ return text;
127
+ }
128
+ function expectNonNegativeNumber(value, label, path) {
129
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
130
+ throw invalid(path, `${label} must be a finite non-negative number`);
131
+ }
132
+ return value;
133
+ }
134
+ function expectObject(value, label, path) {
135
+ if (!isRecord(value))
136
+ throw invalid(path, `${label} must be an object`);
137
+ return value;
138
+ }
139
+ function expectStringArray(value, label, path) {
140
+ if (!Array.isArray(value))
141
+ throw invalid(path, `${label} must be an array`);
142
+ return value.map((item) => expectString(item, label, path));
143
+ }
144
+ function parseInvocation(value, path) {
145
+ const raw = expectObject(value, "launchConfig.invocation", path);
146
+ expectKeys(raw, INVOCATION_FIELDS, "launchConfig.invocation", path);
147
+ return Object.freeze({
148
+ command: expectString(raw.command, "launchConfig.invocation.command", path),
149
+ args: Object.freeze(expectStringArray(raw.args, "launchConfig.invocation.args", path)),
150
+ });
151
+ }
152
+ function parseAgent(value, path) {
153
+ const raw = expectObject(value, "launchConfig.agent", path);
154
+ expectKeys(raw, AGENT_FIELDS, "launchConfig.agent", path);
155
+ if (typeof raw.hidden !== "boolean")
156
+ throw invalid(path, "launchConfig.agent.hidden must be boolean");
157
+ const displayName = expectOptionalString(raw.displayName, "launchConfig.agent.displayName", path);
158
+ const description = expectOptionalString(raw.description, "launchConfig.agent.description", path);
159
+ return Object.freeze({
160
+ name: expectString(raw.name, "launchConfig.agent.name", path),
161
+ ...(displayName === undefined ? {} : { displayName }),
162
+ ...(description === undefined ? {} : { description }),
163
+ hidden: raw.hidden,
164
+ sourcePath: expectString(raw.sourcePath, "launchConfig.agent.sourcePath", path),
165
+ instructions: expectString(raw.instructions, "launchConfig.agent.instructions", path),
166
+ });
167
+ }
168
+ function parseModel(value, path) {
169
+ const raw = expectObject(value, "launchConfig.model", path);
170
+ expectKeys(raw, MODEL_FIELDS, "launchConfig.model", path);
171
+ const source = raw.source;
172
+ if (!isLaunchValueSource(source))
173
+ throw invalid(path, "launchConfig.model.source is invalid");
174
+ return Object.freeze({
175
+ provider: expectString(raw.provider, "launchConfig.model.provider", path),
176
+ id: expectString(raw.id, "launchConfig.model.id", path),
177
+ source,
178
+ });
179
+ }
180
+ function parseThinking(value, path) {
181
+ const raw = expectObject(value, "launchConfig.thinking", path);
182
+ expectKeys(raw, THINKING_FIELDS, "launchConfig.thinking", path);
183
+ const level = raw.level;
184
+ if (!isThinkingLevel(level))
185
+ throw invalid(path, "launchConfig.thinking.level is invalid");
186
+ const source = raw.source;
187
+ if (!isLaunchValueSource(source))
188
+ throw invalid(path, "launchConfig.thinking.source is invalid");
189
+ return Object.freeze({ level, source });
190
+ }
191
+ function parseExtensions(value, path) {
192
+ const raw = expectObject(value, "launchConfig.extensions", path);
193
+ expectKeys(raw, SELECTION_FIELDS, "launchConfig.extensions", path);
194
+ if (typeof raw.discovery !== "boolean") {
195
+ throw invalid(path, "launchConfig.extensions.discovery must be boolean");
196
+ }
197
+ return Object.freeze({
198
+ discovery: raw.discovery,
199
+ paths: Object.freeze(expectStringArray(raw.paths, "launchConfig.extensions.paths", path)),
200
+ });
201
+ }
202
+ function parseSkills(value, path) {
203
+ const raw = expectObject(value, "launchConfig.skills", path);
204
+ expectKeys(raw, SELECTION_FIELDS, "launchConfig.skills", path);
205
+ if (typeof raw.discovery !== "boolean") {
206
+ throw invalid(path, "launchConfig.skills.discovery must be boolean");
207
+ }
208
+ return Object.freeze({
209
+ discovery: raw.discovery,
210
+ paths: Object.freeze(expectStringArray(raw.paths, "launchConfig.skills.paths", path)),
211
+ });
212
+ }
213
+ function parseUsage(value, path) {
214
+ const raw = expectObject(value, "usage", path);
215
+ expectKeys(raw, USAGE_FIELDS, "usage", path);
216
+ const costUsd = raw.costUsd;
217
+ if (costUsd !== null &&
218
+ (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd < 0)) {
219
+ throw invalid(path, "usage.costUsd must be null or a finite non-negative number");
220
+ }
221
+ const turns = expectNonNegativeNumber(raw.turns, "usage.turns", path);
222
+ if (!Number.isInteger(turns))
223
+ throw invalid(path, "usage.turns must be an integer");
224
+ return Object.freeze({
225
+ inputTokens: expectNonNegativeNumber(raw.inputTokens, "usage.inputTokens", path),
226
+ outputTokens: expectNonNegativeNumber(raw.outputTokens, "usage.outputTokens", path),
227
+ cacheReadTokens: expectNonNegativeNumber(raw.cacheReadTokens, "usage.cacheReadTokens", path),
228
+ cacheWriteTokens: expectNonNegativeNumber(raw.cacheWriteTokens, "usage.cacheWriteTokens", path),
229
+ costUsd,
230
+ turns,
231
+ });
232
+ }
233
+ function parseRuntime(value, path) {
234
+ const raw = expectObject(value, "runtime", path);
235
+ expectKeys(raw, RUNTIME_FIELDS, "runtime", path);
236
+ const runtimeIdentity = expectString(raw.runtimeIdentity, "runtime.runtimeIdentity", path);
237
+ const endpoint = expectString(raw.endpoint, "runtime.endpoint", path);
238
+ const pid = raw.pid;
239
+ if (pid === undefined)
240
+ return Object.freeze({ runtimeIdentity, endpoint });
241
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
242
+ throw invalid(path, "runtime.pid must be a positive integer");
243
+ }
244
+ return Object.freeze({ runtimeIdentity, endpoint, pid });
245
+ }
246
+ function parseClaim(value, path) {
247
+ const raw = expectObject(value, "claim", path);
248
+ expectKeys(raw, CLAIM_FIELDS, "claim", path);
249
+ if (raw.kind !== "reconnect" && raw.kind !== "replacement") {
250
+ throw invalid(path, "claim.kind must be reconnect or replacement");
251
+ }
252
+ const holderPid = raw.holderPid;
253
+ if (typeof holderPid !== "number" || !Number.isInteger(holderPid) || holderPid <= 0) {
254
+ throw invalid(path, "claim.holderPid must be a positive integer");
255
+ }
256
+ const tokenHash = expectString(raw.controllerTokenHash, "claim.controllerTokenHash", path);
257
+ if (!/^[0-9a-f]{64}$/u.test(tokenHash)) {
258
+ throw invalid(path, "claim.controllerTokenHash must be a SHA-256 hex digest");
259
+ }
260
+ const runnerPid = raw.runnerPid;
261
+ if (runnerPid !== undefined &&
262
+ (typeof runnerPid !== "number" || !Number.isInteger(runnerPid) || runnerPid <= 0)) {
263
+ throw invalid(path, "claim.runnerPid must be a positive integer");
264
+ }
265
+ return Object.freeze({
266
+ claimId: expectString(raw.claimId, "claim.claimId", path),
267
+ kind: raw.kind,
268
+ holderPid,
269
+ runtimeIdentity: expectString(raw.runtimeIdentity, "claim.runtimeIdentity", path),
270
+ endpoint: expectString(raw.endpoint, "claim.endpoint", path),
271
+ controllerTokenHash: tokenHash,
272
+ ...(runnerPid === undefined ? {} : { runnerPid }),
273
+ });
274
+ }
275
+ function parseLaunchConfig(value, path) {
276
+ const raw = expectObject(value, "launchConfig", path);
277
+ expectKeys(raw, LAUNCH_CONFIG_FIELDS, "launchConfig", path);
278
+ const sessionId = expectString(raw.sessionId, "launchConfig.sessionId", path);
279
+ if (!isSessionId(sessionId))
280
+ throw invalid(path, "launchConfig.sessionId is not a valid session id");
281
+ const sessionPath = expectOptionalString(raw.sessionPath, "launchConfig.sessionPath", path);
282
+ const extensions = parseExtensions(raw.extensions, path);
283
+ const bridgeExtensionPath = expectString(raw.bridgeExtensionPath, "launchConfig.bridgeExtensionPath", path);
284
+ if (!extensions.paths.includes(bridgeExtensionPath)) {
285
+ throw invalid(path, "launchConfig.bridgeExtensionPath is missing from the extension selection");
286
+ }
287
+ const subagentId = expectString(raw.subagentId, "launchConfig.subagentId", path);
288
+ if (!isSessionId(subagentId))
289
+ throw invalid(path, "launchConfig.subagentId is not a valid child id");
290
+ return Object.freeze({
291
+ subagentId,
292
+ invocation: parseInvocation(raw.invocation, path),
293
+ cwd: expectString(raw.cwd, "launchConfig.cwd", path),
294
+ sessionId,
295
+ sessionDir: expectString(raw.sessionDir, "launchConfig.sessionDir", path),
296
+ ...(sessionPath === undefined ? {} : { sessionPath }),
297
+ agent: parseAgent(raw.agent, path),
298
+ model: parseModel(raw.model, path),
299
+ thinking: parseThinking(raw.thinking, path),
300
+ tools: Object.freeze(expectStringArray(raw.tools, "launchConfig.tools", path)),
301
+ excludeTools: Object.freeze(expectStringArray(raw.excludeTools, "launchConfig.excludeTools", path)),
302
+ extensions,
303
+ skills: parseSkills(raw.skills, path),
304
+ prompt: expectString(raw.prompt, "launchConfig.prompt", path),
305
+ bridgeExtensionPath,
306
+ interactive: expectOptionalBoolean(raw.interactive, "launchConfig.interactive", path, false),
307
+ });
308
+ }
309
+ /** Strictly revalidates one record read from disk; partial or unknown shapes never become state. */
310
+ export function parseSubagentRecord(value, key, parentSessionId, path) {
311
+ const raw = expectObject(value, `record ${key}`, path);
312
+ expectKeys(raw, RECORD_FIELDS, `record ${key}`, path);
313
+ const subagentId = expectString(raw.subagentId, "subagentId", path);
314
+ if (subagentId !== key)
315
+ throw invalid(path, `record ${key} declares subagentId ${subagentId}`);
316
+ if (!isSessionId(subagentId))
317
+ throw invalid(path, `record ${key} has an invalid subagentId`);
318
+ if (raw.parentSessionId !== parentSessionId) {
319
+ throw invalid(path, `record ${key} belongs to another parent session`);
320
+ }
321
+ const revision = expectNonNegativeNumber(raw.revision, "revision", path);
322
+ if (!Number.isInteger(revision) || revision < 1) {
323
+ throw invalid(path, `record ${key} revision must be a positive integer`);
324
+ }
325
+ const state = raw.state;
326
+ if (!isSubagentState(state))
327
+ throw invalid(path, `record ${key} state is invalid`);
328
+ const mode = raw.mode;
329
+ if (!isExecutionMode(mode))
330
+ throw invalid(path, `record ${key} mode is invalid`);
331
+ const intent = raw.intent;
332
+ if (!isSubagentIntent(intent))
333
+ throw invalid(path, `record ${key} intent is invalid`);
334
+ const persistence = raw.persistence;
335
+ if (!isPersistenceState(persistence))
336
+ throw invalid(path, `record ${key} persistence is invalid`);
337
+ const sessionId = expectString(raw.sessionId, "sessionId", path);
338
+ if (!isSessionId(sessionId))
339
+ throw invalid(path, `record ${key} sessionId is not a valid session id`);
340
+ const sessionPath = expectOptionalString(raw.sessionPath, "sessionPath", path);
341
+ const launchConfig = parseLaunchConfig(raw.launchConfig, path);
342
+ if (launchConfig.subagentId !== subagentId) {
343
+ throw invalid(path, `record ${key} subagentId disagrees with its launch config`);
344
+ }
345
+ if (launchConfig.sessionId !== sessionId) {
346
+ throw invalid(path, `record ${key} sessionId disagrees with its launch config`);
347
+ }
348
+ if (launchConfig.cwd !== expectString(raw.cwd, "cwd", path)) {
349
+ throw invalid(path, `record ${key} cwd disagrees with its launch config`);
350
+ }
351
+ if (persistence === "flushed") {
352
+ if (sessionPath === undefined || launchConfig.sessionPath !== sessionPath) {
353
+ throw invalid(path, `record ${key} claims a flushed session without a matching path`);
354
+ }
355
+ }
356
+ else if (sessionPath !== undefined || launchConfig.sessionPath !== undefined) {
357
+ throw invalid(path, `record ${key} claims a never-flushed session with a session path`);
358
+ }
359
+ const runtime = raw.runtime === undefined ? undefined : parseRuntime(raw.runtime, path);
360
+ const claim = raw.claim === undefined ? undefined : parseClaim(raw.claim, path);
361
+ const usage = raw.usage === undefined ? undefined : parseUsage(raw.usage, path);
362
+ const latestSummary = expectOptionalString(raw.latestSummary, "latestSummary", path);
363
+ const interrupted = expectOptionalString(raw.interrupted, "interrupted", path);
364
+ const unacknowledgedInput = expectOptionalString(raw.unacknowledgedInput, "unacknowledgedInput", path);
365
+ return Object.freeze({
366
+ subagentId,
367
+ parentSessionId,
368
+ revision,
369
+ createdAt: expectIsoDate(raw.createdAt, "createdAt", path),
370
+ updatedAt: expectIsoDate(raw.updatedAt, "updatedAt", path),
371
+ sessionId,
372
+ ...(sessionPath === undefined ? {} : { sessionPath }),
373
+ cwd: launchConfig.cwd,
374
+ initialTask: expectString(raw.initialTask, "initialTask", path),
375
+ intent,
376
+ state,
377
+ mode,
378
+ persistence,
379
+ launchConfig,
380
+ ...(runtime === undefined ? {} : { runtime }),
381
+ ...(claim === undefined ? {} : { claim }),
382
+ ...(latestSummary === undefined ? {} : { latestSummary }),
383
+ ...(usage === undefined ? {} : { usage }),
384
+ ...(interrupted === undefined ? {} : { interrupted }),
385
+ ...(unacknowledgedInput === undefined ? {} : { unacknowledgedInput }),
386
+ });
387
+ }
388
+ function parseRoot(raw, path, parentSessionId) {
389
+ if (Object.keys(raw).length === 0)
390
+ return new Map();
391
+ const version = raw.version;
392
+ if (version === undefined)
393
+ throw corrupt(path, "missing version");
394
+ if (version !== REGISTRY_VERSION) {
395
+ throw new SubagentRegistryError("unsupported_version", `Unsupported registry version ${String(version)} in ${path}; expected ${REGISTRY_VERSION}`);
396
+ }
397
+ if (raw.parentSessionId !== parentSessionId) {
398
+ throw new SubagentRegistryError("parent_mismatch", `Registry ${path} belongs to parent session ${String(raw.parentSessionId)}, not ${parentSessionId}`);
399
+ }
400
+ const records = raw.records;
401
+ if (!isRecord(records))
402
+ throw corrupt(path, "records must be an object");
403
+ const parsed = new Map();
404
+ for (const [id, value] of Object.entries(records)) {
405
+ parsed.set(id, parseSubagentRecord(value, id, parentSessionId, path));
406
+ }
407
+ return parsed;
408
+ }
409
+ function writeRoot(root, parentSessionId, records, now) {
410
+ for (const key of Object.keys(root)) {
411
+ if (ROOT_FIELDS[key] !== true)
412
+ delete root[key];
413
+ }
414
+ root.version = REGISTRY_VERSION;
415
+ root.parentSessionId = parentSessionId;
416
+ root.updatedAt = now.toISOString();
417
+ root.records = Object.fromEntries(records);
418
+ }
419
+ export function createSubagentRegistry(options) {
420
+ const { parentSessionId } = options;
421
+ if (!isSessionId(parentSessionId)) {
422
+ throw new SubagentRegistryError("invalid_parent_session", `Invalid parent session id ${parentSessionId}`);
423
+ }
424
+ const path = options.filePath ?? defaultRegistryPath(parentSessionId);
425
+ const now = options.now ?? (() => new Date());
426
+ const readRecords = async (signal) => parseRoot(await readJsonSettingsRoot(path, signal), path, parentSessionId);
427
+ const store = {
428
+ path,
429
+ parentSessionId,
430
+ async get(id, signal) {
431
+ return (await readRecords(signal)).get(id);
432
+ },
433
+ async list(signal) {
434
+ const records = [...(await readRecords(signal)).values()];
435
+ records.sort((left, right) => left.createdAt === right.createdAt
436
+ ? left.subagentId.localeCompare(right.subagentId)
437
+ : left.createdAt.localeCompare(right.createdAt));
438
+ return records;
439
+ },
440
+ async create(record, signal) {
441
+ let stored;
442
+ await updateJsonSettingsRoot(path, (root) => {
443
+ const records = parseRoot(root, path, parentSessionId);
444
+ const candidate = parseSubagentRecord(record, record.subagentId, parentSessionId, path);
445
+ if (records.has(candidate.subagentId)) {
446
+ throw new SubagentRegistryError("duplicate_child", `Child ${candidate.subagentId} already exists in ${path}`);
447
+ }
448
+ records.set(candidate.subagentId, candidate);
449
+ writeRoot(root, parentSessionId, records, now());
450
+ stored = candidate;
451
+ }, signal);
452
+ if (stored === undefined)
453
+ throw corrupt(path, "create did not persist a record");
454
+ return stored;
455
+ },
456
+ async update(id, expectedRevision, updater, expectedRuntimeIdentity, signal) {
457
+ let stored;
458
+ await updateJsonSettingsRoot(path, (root) => {
459
+ const records = parseRoot(root, path, parentSessionId);
460
+ const current = records.get(id);
461
+ if (current === undefined) {
462
+ throw new SubagentRegistryError("unknown_child", `Unknown child ${id} in ${path}`);
463
+ }
464
+ if (current.revision !== expectedRevision) {
465
+ throw new SubagentRegistryError("stale_revision", `Child ${id} is at revision ${current.revision}, not ${expectedRevision}`);
466
+ }
467
+ if (expectedRuntimeIdentity !== undefined &&
468
+ current.runtime?.runtimeIdentity !== expectedRuntimeIdentity) {
469
+ throw new SubagentRegistryError("runtime_mismatch", `Child ${id} runtime is ${current.runtime?.runtimeIdentity ?? "none"}, not ${expectedRuntimeIdentity}`);
470
+ }
471
+ const next = parseSubagentRecord({
472
+ ...updater(current),
473
+ subagentId: current.subagentId,
474
+ parentSessionId: current.parentSessionId,
475
+ createdAt: current.createdAt,
476
+ revision: current.revision + 1,
477
+ updatedAt: now().toISOString(),
478
+ }, id, parentSessionId, path);
479
+ records.set(id, next);
480
+ writeRoot(root, parentSessionId, records, now());
481
+ stored = next;
482
+ }, signal);
483
+ if (stored === undefined)
484
+ throw corrupt(path, `update of ${id} did not persist a record`);
485
+ return stored;
486
+ },
487
+ async claim(id, expectedRevision, candidate, expectedRuntimeIdentity, expectedClaimId, signal) {
488
+ return store.update(id, expectedRevision, (current) => {
489
+ if (current.intent === "stopped") {
490
+ throw new SubagentRegistryError("stopped_child", `Child ${id} is stopped`);
491
+ }
492
+ if ((expectedClaimId === undefined && current.claim !== undefined) ||
493
+ (expectedClaimId !== undefined && current.claim?.claimId !== expectedClaimId)) {
494
+ throw new SubagentRegistryError("claim_conflict", `Child ${id} has another claim`);
495
+ }
496
+ if (candidate.kind === "reconnect") {
497
+ if (current.runtime?.runtimeIdentity !== candidate.runtimeIdentity ||
498
+ current.runtime.endpoint !== candidate.endpoint) {
499
+ throw new SubagentRegistryError("runtime_mismatch", `Child ${id} runtime changed before reconnect claim`);
500
+ }
501
+ }
502
+ else if (expectedRuntimeIdentity === undefined
503
+ ? current.runtime !== undefined
504
+ : current.runtime?.runtimeIdentity !== expectedRuntimeIdentity) {
505
+ throw new SubagentRegistryError("runtime_mismatch", `Child ${id} runtime changed before replacement claim`);
506
+ }
507
+ return { ...current, claim: candidate };
508
+ }, undefined, signal);
509
+ },
510
+ async markClaimRunner(id, claimId, runnerPid, signal) {
511
+ const current = await store.get(id, signal);
512
+ if (current === undefined)
513
+ throw new SubagentRegistryError("unknown_child", `Unknown child ${id} in ${path}`);
514
+ return store.update(id, current.revision, (value) => {
515
+ if (value.claim?.claimId !== claimId || value.claim.kind !== "replacement") {
516
+ throw new SubagentRegistryError("claim_mismatch", `Child ${id} claim changed`);
517
+ }
518
+ return { ...value, claim: { ...value.claim, runnerPid } };
519
+ }, undefined, signal);
520
+ },
521
+ async activateClaim(id, claimId, runnerPid, signal) {
522
+ const current = await store.get(id, signal);
523
+ if (current === undefined)
524
+ throw new SubagentRegistryError("unknown_child", `Unknown child ${id} in ${path}`);
525
+ return store.update(id, current.revision, (value) => {
526
+ const claim = value.claim;
527
+ if (claim?.claimId !== claimId ||
528
+ claim.kind !== "replacement" ||
529
+ (claim.runnerPid !== undefined && claim.runnerPid !== runnerPid) ||
530
+ value.intent === "stopped") {
531
+ throw new SubagentRegistryError("claim_mismatch", `Child ${id} replacement claim is no longer valid`);
532
+ }
533
+ const { claim: _claim, ...withoutClaim } = value;
534
+ return {
535
+ ...withoutClaim,
536
+ runtime: {
537
+ runtimeIdentity: claim.runtimeIdentity,
538
+ endpoint: claim.endpoint,
539
+ pid: runnerPid,
540
+ },
541
+ };
542
+ }, undefined, signal);
543
+ },
544
+ async consumeReconnectClaim(id, claimId, controllerTokenHash, signal) {
545
+ const current = await store.get(id, signal);
546
+ if (current === undefined)
547
+ throw new SubagentRegistryError("unknown_child", `Unknown child ${id} in ${path}`);
548
+ return store.update(id, current.revision, (value) => {
549
+ const claim = value.claim;
550
+ if (claim?.claimId !== claimId ||
551
+ claim.kind !== "reconnect" ||
552
+ claim.controllerTokenHash !== controllerTokenHash ||
553
+ value.runtime?.runtimeIdentity !== claim.runtimeIdentity ||
554
+ value.intent === "stopped") {
555
+ throw new SubagentRegistryError("claim_mismatch", `Child ${id} reconnect claim is no longer valid`);
556
+ }
557
+ const { claim: _claim, ...withoutClaim } = value;
558
+ return withoutClaim;
559
+ }, current.runtime?.runtimeIdentity, signal);
560
+ },
561
+ async releaseClaim(id, claimId, signal) {
562
+ const current = await store.get(id, signal);
563
+ if (current === undefined)
564
+ throw new SubagentRegistryError("unknown_child", `Unknown child ${id} in ${path}`);
565
+ return store.update(id, current.revision, (value) => {
566
+ if (value.claim?.claimId !== claimId) {
567
+ throw new SubagentRegistryError("claim_mismatch", `Child ${id} claim changed`);
568
+ }
569
+ const { claim: _claim, ...withoutClaim } = value;
570
+ return withoutClaim;
571
+ }, undefined, signal);
572
+ },
573
+ };
574
+ return store;
575
+ }
@@ -0,0 +1,40 @@
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { type RunnerOperation } from "./protocol.js";
3
+ /**
4
+ * Runner operations that map onto a real Pi `--mode rpc` command. Runner-local
5
+ * shutdown, writer handoff, and contact_parent operations are never forwarded to Pi.
6
+ */
7
+ export type PiRpcOperation = Exclude<RunnerOperation, "shutdown" | "contact_parent" | "report_lifecycle" | "report_paused" | "pause" | "cancel_pause" | "close_writer" | "start_rpc">;
8
+ /** A forwarded Pi RPC command did not answer within its deadline. */
9
+ export declare class PiRpcTimeoutError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ export interface PiRpcAdapterOptions {
13
+ readonly process: ChildProcessWithoutNullStreams;
14
+ readonly maxFrameBytes?: number;
15
+ readonly maxPendingRequests?: number;
16
+ readonly requestTimeoutMs?: number;
17
+ /**
18
+ * Receives exceptions thrown by event listeners. A listener that throws must
19
+ * not be mistaken for a malformed Pi frame, so the error is reported here.
20
+ */
21
+ readonly onListenerError?: (error: unknown) => void;
22
+ }
23
+ /**
24
+ * Thin request/response transport over one Pi `--mode rpc` child process.
25
+ *
26
+ * Pi stdout is RPC JSON lines only. Responses are matched to in-flight requests
27
+ * by request ID; everything else is delivered to event listeners unchanged.
28
+ */
29
+ export declare class PiRpcAdapter {
30
+ #private;
31
+ constructor(options: PiRpcAdapterOptions);
32
+ onEvent(listener: (event: unknown) => void): () => void;
33
+ /** Confirms readiness with a real `get_state` round trip. */
34
+ ready(signal?: AbortSignal): Promise<unknown>;
35
+ request(operation: PiRpcOperation, payload?: unknown, options?: {
36
+ readonly signal?: AbortSignal;
37
+ readonly timeoutMs?: number;
38
+ }): Promise<unknown>;
39
+ close(reason?: Error): void;
40
+ }