@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,11 @@
1
+ /**
2
+ * Node has no flock(2). A dedicated SQLite file held in BEGIN EXCLUSIVE uses the kernel POSIX
3
+ * lock, which is released when this process dies, including SIGKILL. A missed poll is not death.
4
+ * The parent must not keep the lock: a probe rolls back immediately.
5
+ */
6
+ export interface ProcessLock {
7
+ release(): void;
8
+ }
9
+ export declare function processLockPath(stateFile: string, parentSessionId: string, subagentId: string): string | undefined;
10
+ export declare function holdProcessLock(lockPath: string): ProcessLock;
11
+ export declare function processLockHeld(lockPath: string): boolean;
@@ -0,0 +1,55 @@
1
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { isSessionId } from "./domain.js";
5
+ export function processLockPath(stateFile, parentSessionId, subagentId) {
6
+ if (!isSessionId(parentSessionId) || !isSessionId(subagentId))
7
+ return undefined;
8
+ return join(dirname(stateFile), "locks", parentSessionId, `${subagentId}.lock`);
9
+ }
10
+ export function holdProcessLock(lockPath) {
11
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
12
+ const db = new DatabaseSync(lockPath);
13
+ chmodSync(lockPath, 0o600);
14
+ db.exec("PRAGMA busy_timeout = 2000");
15
+ db.exec("BEGIN EXCLUSIVE");
16
+ let released = false;
17
+ return {
18
+ release() {
19
+ if (released)
20
+ return;
21
+ released = true;
22
+ try {
23
+ db.exec("ROLLBACK");
24
+ }
25
+ catch {
26
+ // Closing the connection still drops the kernel lock.
27
+ }
28
+ db.close();
29
+ },
30
+ };
31
+ }
32
+ export function processLockHeld(lockPath) {
33
+ if (!existsSync(lockPath))
34
+ return false;
35
+ const db = new DatabaseSync(lockPath);
36
+ try {
37
+ db.exec("PRAGMA busy_timeout = 0");
38
+ try {
39
+ db.exec("BEGIN IMMEDIATE");
40
+ }
41
+ catch (error) {
42
+ if (isDatabaseLocked(error))
43
+ return true;
44
+ throw error;
45
+ }
46
+ db.exec("ROLLBACK");
47
+ return false;
48
+ }
49
+ finally {
50
+ db.close();
51
+ }
52
+ }
53
+ function isDatabaseLocked(error) {
54
+ return typeof error === "object" && error !== null && "errcode" in error && error.errcode === 5;
55
+ }
@@ -7,7 +7,7 @@ export declare const DEFAULT_MAX_BUFFERED_EVENTS = 256;
7
7
  * input and state operations; the child returns reports and its Task result as requests, so a
8
8
  * report the parent refuses (a second Task result) is refused to the child that sent it.
9
9
  */
10
- export declare const BRIDGE_OPERATIONS: readonly ["prompt", "steer", "follow_up", "abort", "get_state", "get_entries", "shutdown", "contact_parent", "task_result"];
10
+ export declare const BRIDGE_OPERATIONS: readonly ["prompt", "steer", "follow_up", "abort", "get_state", "get_entries", "session_facts", "shutdown", "contact_parent", "task_result"];
11
11
  export type BridgeOperation = (typeof BRIDGE_OPERATIONS)[number];
12
12
  /** Child-branch event: the child left the bound session, quit, or was interrupted. */
13
13
  export declare const CHILD_LIFECYCLE_EVENT: "child_lifecycle";
@@ -41,7 +41,7 @@ export declare const RequestFrameSchema: Type.TObject<{
41
41
  version: Type.TLiteral<1>;
42
42
  type: Type.TLiteral<"request">;
43
43
  id: Type.TString;
44
- operation: Type.TUnion<Type.TLiteral<"contact_parent" | "steer" | "follow_up" | "prompt" | "abort" | "get_state" | "get_entries" | "shutdown" | "task_result">[]>;
44
+ operation: Type.TUnion<Type.TLiteral<"contact_parent" | "steer" | "follow_up" | "prompt" | "abort" | "get_state" | "get_entries" | "session_facts" | "shutdown" | "task_result">[]>;
45
45
  payload: Type.TOptional<Type.TUnknown>;
46
46
  }>;
47
47
  export declare const BridgeErrorSchema: Type.TObject<{
package/dist/protocol.js CHANGED
@@ -16,6 +16,7 @@ export const BRIDGE_OPERATIONS = [
16
16
  "abort",
17
17
  "get_state",
18
18
  "get_entries",
19
+ "session_facts",
19
20
  "shutdown",
20
21
  "contact_parent",
21
22
  "task_result",
@@ -30,7 +30,7 @@ export interface CreateSubagentRegistryOptions {
30
30
  readonly filePath?: string;
31
31
  readonly now?: () => Date;
32
32
  }
33
- export declare function defaultRegistryPath(parentSessionId: string): string;
34
33
  /** Strictly revalidates one record read from disk; partial or unknown shapes never become state. */
35
34
  export declare function parseSubagentRecord(value: unknown, key: string, parentSessionId: string, path: string): SubagentRecord;
35
+ export declare function defaultRegistryPath(_parentSessionId: string): string;
36
36
  export declare function createSubagentRegistry(options: CreateSubagentRegistryOptions): SubagentRegistry;
package/dist/registry.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { join } from "node:path";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import { isRecord, readJsonSettingsRoot, updateJsonSettingsRoot } from "@hheei/pi-ext-core";
4
- import { isLaunchValueSource, isPersistenceState, isPresentation, isSessionId, isSubagentIntent, isSubagentState, isThinkingLevel, normalizeSubagentState, REGISTRY_VERSION, } from "./domain.js";
3
+ import { isRecord } from "@hheei/pi-ext-core";
4
+ import { isLaunchValueSource, isPersistenceState, isPresentation, isSessionId, isSubagentIntent, isSubagentState, isThinkingLevel, normalizeSubagentState, } from "./domain.js";
5
+ import { insertChild, listChildRecords, openStateDb, readChild, replaceChild, withWrite, } from "./state-db.js";
5
6
  export class SubagentRegistryError extends Error {
6
7
  code;
7
8
  constructor(code, message) {
@@ -10,15 +11,6 @@ export class SubagentRegistryError extends Error {
10
11
  this.code = code;
11
12
  }
12
13
  }
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
14
  const RECORD_FIELDS = {
23
15
  subagentId: true,
24
16
  parentSessionId: true,
@@ -74,7 +66,12 @@ const INVOCATION_FIELDS = { command: true, args: true };
74
66
  const MODEL_FIELDS = { provider: true, id: true, source: true };
75
67
  const THINKING_FIELDS = { level: true, source: true };
76
68
  const SELECTION_FIELDS = { discovery: true, paths: true };
77
- const RUNTIME_FIELDS = { runtimeIdentity: true, endpoint: true };
69
+ const RUNTIME_FIELDS = {
70
+ runtimeIdentity: true,
71
+ endpoint: true,
72
+ attachment: true,
73
+ };
74
+ const ATTACHMENT_FIELDS = { kind: true, host: true, id: true, pid: true };
78
75
  const USAGE_FIELDS = {
79
76
  inputTokens: true,
80
77
  outputTokens: true,
@@ -218,7 +215,41 @@ function parseRuntime(value, path) {
218
215
  expectKeys(raw, RUNTIME_FIELDS, "runtime", path);
219
216
  const runtimeIdentity = expectString(raw.runtimeIdentity, "runtime.runtimeIdentity", path);
220
217
  const endpoint = expectString(raw.endpoint, "runtime.endpoint", path);
221
- return Object.freeze({ runtimeIdentity, endpoint });
218
+ const attachment = raw.attachment === undefined ? undefined : parseAttachment(raw.attachment, path);
219
+ return Object.freeze({
220
+ runtimeIdentity,
221
+ endpoint,
222
+ ...(attachment === undefined ? {} : { attachment }),
223
+ });
224
+ }
225
+ function parseAttachment(value, path) {
226
+ const raw = expectObject(value, "runtime.attachment", path);
227
+ expectKeys(raw, ATTACHMENT_FIELDS, "runtime.attachment", path);
228
+ if (raw.kind === "panel") {
229
+ if (raw.pid !== undefined)
230
+ throw invalid(path, "runtime.attachment panel must not carry a pid");
231
+ const host = raw.host;
232
+ if (!isPanelHost(host))
233
+ throw invalid(path, "runtime.attachment.host is invalid");
234
+ const id = expectString(raw.id, "runtime.attachment.id", path);
235
+ if (id === "")
236
+ throw invalid(path, "runtime.attachment.id must not be empty");
237
+ return Object.freeze({ kind: "panel", host, id });
238
+ }
239
+ if (raw.kind === "process") {
240
+ if (raw.host !== undefined || raw.id !== undefined) {
241
+ throw invalid(path, "runtime.attachment process must not carry a panel id");
242
+ }
243
+ const pid = raw.pid;
244
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
245
+ throw invalid(path, "runtime.attachment.pid must be a positive integer");
246
+ }
247
+ return Object.freeze({ kind: "process", pid });
248
+ }
249
+ throw invalid(path, "runtime.attachment.kind is invalid");
250
+ }
251
+ function isPanelHost(value) {
252
+ return value === "herdr" || value === "cmux";
222
253
  }
223
254
  function parseMcpSelection(value, path) {
224
255
  if (value === undefined)
@@ -363,36 +394,8 @@ export function parseSubagentRecord(value, key, parentSessionId, path) {
363
394
  ...(unacknowledgedInput === undefined ? {} : { unacknowledgedInput }),
364
395
  });
365
396
  }
366
- function parseRoot(raw, path, parentSessionId) {
367
- if (Object.keys(raw).length === 0)
368
- return new Map();
369
- const version = raw.version;
370
- if (version === undefined)
371
- throw corrupt(path, "missing version");
372
- if (version !== REGISTRY_VERSION) {
373
- throw new SubagentRegistryError("unsupported_version", `Unsupported registry version ${String(version)} in ${path}; expected ${REGISTRY_VERSION}`);
374
- }
375
- if (raw.parentSessionId !== parentSessionId) {
376
- throw new SubagentRegistryError("parent_mismatch", `Registry ${path} belongs to parent session ${String(raw.parentSessionId)}, not ${parentSessionId}`);
377
- }
378
- const records = raw.records;
379
- if (!isRecord(records))
380
- throw corrupt(path, "records must be an object");
381
- const parsed = new Map();
382
- for (const [id, value] of Object.entries(records)) {
383
- parsed.set(id, parseSubagentRecord(value, id, parentSessionId, path));
384
- }
385
- return parsed;
386
- }
387
- function writeRoot(root, parentSessionId, records, now) {
388
- for (const key of Object.keys(root)) {
389
- if (ROOT_FIELDS[key] !== true)
390
- delete root[key];
391
- }
392
- root.version = REGISTRY_VERSION;
393
- root.parentSessionId = parentSessionId;
394
- root.updatedAt = now.toISOString();
395
- root.records = Object.fromEntries(records);
397
+ export function defaultRegistryPath(_parentSessionId) {
398
+ return join(getAgentDir(), "pi-subagents", "state.sqlite");
396
399
  }
397
400
  export function createSubagentRegistry(options) {
398
401
  const { parentSessionId } = options;
@@ -401,41 +404,43 @@ export function createSubagentRegistry(options) {
401
404
  }
402
405
  const path = options.filePath ?? defaultRegistryPath(parentSessionId);
403
406
  const now = options.now ?? (() => new Date());
404
- const readRecords = async (signal) => parseRoot(await readJsonSettingsRoot(path, signal), path, parentSessionId);
405
- const store = {
407
+ const db = openStateDb(path);
408
+ const load = (id) => {
409
+ const row = readChild(db, parentSessionId, id);
410
+ if (row === undefined)
411
+ return undefined;
412
+ return parseSubagentRecord(JSON.parse(row.record), id, parentSessionId, path);
413
+ };
414
+ return {
406
415
  path,
407
416
  parentSessionId,
408
- async get(id, signal) {
409
- return (await readRecords(signal)).get(id);
417
+ async get(id) {
418
+ return load(id);
410
419
  },
411
- async list(signal) {
412
- const records = [...(await readRecords(signal)).values()];
420
+ async list() {
421
+ const records = listChildRecords(db, parentSessionId).map((record) => {
422
+ const parsed = JSON.parse(record);
423
+ if (!isRecord(parsed) || typeof parsed.subagentId !== "string") {
424
+ throw corrupt(path, "child record is missing subagentId");
425
+ }
426
+ return parseSubagentRecord(parsed, parsed.subagentId, parentSessionId, path);
427
+ });
413
428
  records.sort((left, right) => left.createdAt === right.createdAt
414
429
  ? left.subagentId.localeCompare(right.subagentId)
415
430
  : left.createdAt.localeCompare(right.createdAt));
416
431
  return records;
417
432
  },
418
- async create(record, signal) {
419
- let stored;
420
- await updateJsonSettingsRoot(path, (root) => {
421
- const records = parseRoot(root, path, parentSessionId);
422
- const candidate = parseSubagentRecord(record, record.subagentId, parentSessionId, path);
423
- if (records.has(candidate.subagentId)) {
424
- throw new SubagentRegistryError("duplicate_child", `Child ${candidate.subagentId} already exists in ${path}`);
425
- }
426
- records.set(candidate.subagentId, candidate);
427
- writeRoot(root, parentSessionId, records, now());
428
- stored = candidate;
429
- }, signal);
430
- if (stored === undefined)
431
- throw corrupt(path, "create did not persist a record");
432
- return stored;
433
+ async create(record) {
434
+ const candidate = parseSubagentRecord(record, record.subagentId, parentSessionId, path);
435
+ const inserted = withWrite(db, () => insertChild(db, parentSessionId, candidate.subagentId, candidate.revision, JSON.stringify(candidate)));
436
+ if (!inserted) {
437
+ throw new SubagentRegistryError("duplicate_child", `Child ${candidate.subagentId} already exists in ${path}`);
438
+ }
439
+ return candidate;
433
440
  },
434
- async update(id, expectedRevision, updater, expectedRuntimeIdentity, signal) {
435
- let stored;
436
- await updateJsonSettingsRoot(path, (root) => {
437
- const records = parseRoot(root, path, parentSessionId);
438
- const current = records.get(id);
441
+ async update(id, expectedRevision, updater, expectedRuntimeIdentity) {
442
+ return withWrite(db, () => {
443
+ const current = load(id);
439
444
  if (current === undefined) {
440
445
  throw new SubagentRegistryError("unknown_child", `Unknown child ${id} in ${path}`);
441
446
  }
@@ -454,14 +459,12 @@ export function createSubagentRegistry(options) {
454
459
  revision: current.revision + 1,
455
460
  updatedAt: now().toISOString(),
456
461
  }, id, parentSessionId, path);
457
- records.set(id, next);
458
- writeRoot(root, parentSessionId, records, now());
459
- stored = next;
460
- }, signal);
461
- if (stored === undefined)
462
- throw corrupt(path, `update of ${id} did not persist a record`);
463
- return stored;
462
+ const replaced = replaceChild(db, parentSessionId, id, current.revision, next.revision, JSON.stringify(next));
463
+ if (!replaced) {
464
+ throw new SubagentRegistryError("stale_revision", `Child ${id} changed while it was being updated`);
465
+ }
466
+ return next;
467
+ });
464
468
  },
465
469
  };
466
- return store;
467
470
  }
@@ -2,5 +2,5 @@
2
2
  * The Pi events a child forwards to its parent. Everything else — streaming token noise,
3
3
  * undocumented internals — is dropped, so the parent's projection only sees turn-level facts.
4
4
  */
5
- export declare const FORWARDED_PI_EVENT_TYPES: readonly ["agent_start", "agent_end", "turn_start", "turn_end", "tool_execution_start", "tool_execution_end", "auto_retry", "auto_retry_start", "auto_retry_end", "message_update", "agent_settled"];
5
+ export declare const FORWARDED_PI_EVENT_TYPES: readonly ["agent_start", "agent_end", "turn_start", "turn_end", "tool_execution_start", "tool_execution_end", "auto_retry", "auto_retry_start", "auto_retry_end", "message_update", "agent_settled", "compaction_start", "compaction_end"];
6
6
  export declare function shouldForwardPiEvent(event: unknown): boolean;
@@ -15,6 +15,8 @@ export const FORWARDED_PI_EVENT_TYPES = [
15
15
  "auto_retry_end",
16
16
  "message_update",
17
17
  "agent_settled",
18
+ "compaction_start",
19
+ "compaction_end",
18
20
  ];
19
21
  export function shouldForwardPiEvent(event) {
20
22
  if (!isRecord(event))
package/dist/runtime.d.ts CHANGED
@@ -19,22 +19,16 @@ export interface RuntimeTokenStoreOptions {
19
19
  * The one socket a parent session listens on. A child dials this path, so a parent that restarts
20
20
  * re-listens in the same place and a surviving child reconnects instead of being rebuilt.
21
21
  */
22
- export declare function parentBridgeEndpoint(parentSessionId: string): string;
22
+ export declare function parentBridgeEndpoint(_parentSessionId: string): string;
23
+ /** Tokens live in the same sqlite file as the child ledger. Rows do not clobber each other. */
24
+ export declare function parentTokenFile(_parentSessionId: string): string;
23
25
  /**
24
- * Every parent session keeps its own token file. One shared file is rewritten wholesale by whichever
25
- * parent saved last, so two parent sessions in the same runtime directory would erase each other's
26
- * credentials and a child that survived a restart could no longer authorize itself.
27
- */
28
- export declare function parentTokenFile(parentSessionId: string): string;
29
- /**
30
- * Tokens outlive the parent process: a child that survived (a panel child, or one whose parent was
31
- * killed) reconnects with the token it was launched with, and a restarted parent must be able to
32
- * authorize it without holding the old process's memory. The store is a 0600 file in the 0700
33
- * runtime directory; the registry never sees a token.
26
+ * Tokens outlive the parent process. A restarted parent reads the same sqlite file and can still
27
+ * match the token a surviving child was launched with.
34
28
  */
35
29
  export declare function createRuntimeTokenStore(options: RuntimeTokenStoreOptions): RuntimeTokenStore;
36
30
  export interface LaunchChildOptions {
37
- readonly registry: Pick<SubagentRegistry, "update">;
31
+ readonly registry: Pick<SubagentRegistry, "get" | "update">;
38
32
  readonly record: SubagentRecord;
39
33
  readonly tokens: RuntimeTokenStore;
40
34
  /** Waits for this child's bridge connection; false when it did not connect in time. */
@@ -53,19 +47,12 @@ export interface OpenChildPanelOptions extends LaunchChildOptions {
53
47
  readonly host: HostAdapter;
54
48
  }
55
49
  /**
56
- * What a launch left behind. A launch that failed can still have started something — a child that
57
- * never dialed in, or a process that ignores SIGTERM — and then the caller must keep the handle:
58
- * dropping it would let the next send start a second runtime for a session the first one may still
59
- * own. `failure` is what the caller reports.
50
+ * What a launch left behind. `failure` is what the caller reports. A handle is kept when the host
51
+ * still has a tab, so stop can close it. It does not by itself forbid another send.
60
52
  */
61
53
  export interface LaunchOutcome<T> {
62
54
  readonly handle?: T;
63
55
  readonly failure?: string;
64
- /**
65
- * Set when something may still own the child's session and this process cannot observe it, so
66
- * the caller refuses to start a second runtime until an explicit stop.
67
- */
68
- readonly unconfirmed?: boolean;
69
56
  }
70
57
  /**
71
58
  * Opens one child in a host panel and waits for it to connect. The host owns this process, so the
package/dist/runtime.js CHANGED
@@ -7,13 +7,14 @@
7
7
  * registry only records which runtime and endpoint a child was last launched with.
8
8
  */
9
9
  import { randomBytes, randomUUID } from "node:crypto";
10
- import { mkdirSync, readFileSync } from "node:fs";
11
- import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
10
+ import { chmod, mkdir } from "node:fs/promises";
12
11
  import { tmpdir } from "node:os";
13
12
  import { join } from "node:path";
14
13
  import { errorMessage } from "@hheei/pi-ext-core";
15
14
  import { spawnChildRuntime } from "./child-process.js";
16
15
  import { buildLaunchSpec, withBridgeToken } from "./launch-spec.js";
16
+ import { commitLease } from "./lease.js";
17
+ import { defaultStatePath, deleteToken, openStateDb, readToken, writeToken } from "./state-db.js";
17
18
  function runtimeDirectory() {
18
19
  const user = typeof process.getuid === "function" ? process.getuid() : "user";
19
20
  return join(tmpdir(), `pi-subagents-${user}`);
@@ -22,82 +23,46 @@ function runtimeDirectory() {
22
23
  * The one socket a parent session listens on. A child dials this path, so a parent that restarts
23
24
  * re-listens in the same place and a surviving child reconnects instead of being rebuilt.
24
25
  */
25
- export function parentBridgeEndpoint(parentSessionId) {
26
- return join(runtimeDirectory(), `parent-${parentSessionId}.sock`);
26
+ export function parentBridgeEndpoint(_parentSessionId) {
27
+ return defaultStatePath();
27
28
  }
28
- /**
29
- * Every parent session keeps its own token file. One shared file is rewritten wholesale by whichever
30
- * parent saved last, so two parent sessions in the same runtime directory would erase each other's
31
- * credentials and a child that survived a restart could no longer authorize itself.
32
- */
33
- export function parentTokenFile(parentSessionId) {
34
- return join(runtimeDirectory(), `tokens-${parentSessionId}.json`);
29
+ /** Tokens live in the same sqlite file as the child ledger. Rows do not clobber each other. */
30
+ export function parentTokenFile(_parentSessionId) {
31
+ return defaultStatePath();
35
32
  }
36
33
  /**
37
- * Tokens outlive the parent process: a child that survived (a panel child, or one whose parent was
38
- * killed) reconnects with the token it was launched with, and a restarted parent must be able to
39
- * authorize it without holding the old process's memory. The store is a 0600 file in the 0700
40
- * runtime directory; the registry never sees a token.
34
+ * Tokens outlive the parent process. A restarted parent reads the same sqlite file and can still
35
+ * match the token a surviving child was launched with.
41
36
  */
42
37
  export function createRuntimeTokenStore(options) {
43
- const file = options.file ?? parentTokenFile(options.parentSessionId);
38
+ const file = options.file ?? defaultStatePath();
44
39
  const diagnose = options.diagnose ?? (() => { });
45
- const tokens = new Map(readStoredTokens(file, diagnose));
46
- let writing = Promise.resolve();
47
- const persist = () => {
48
- const payload = `${JSON.stringify(Object.fromEntries(tokens))}\n`;
49
- writing = writing
50
- .then(async () => {
51
- mkdirSync(runtimeDirectory(), { recursive: true, mode: 0o700 });
52
- // Writing in place truncates the file first, and a parent killed inside that window would
53
- // leave every surviving child locked out of the bridge. The swap is atomic instead.
54
- const temporary = `${file}.${process.pid}.tmp`;
55
- await writeFile(temporary, payload, { mode: 0o600 });
56
- await rename(temporary, file);
57
- })
58
- .catch((error) => {
59
- diagnose(`could not persist bridge tokens: ${errorMessage(error)}`);
60
- });
61
- };
40
+ let db;
41
+ try {
42
+ db = openStateDb(file);
43
+ }
44
+ catch (error) {
45
+ diagnose(`could not open the state database: ${errorMessage(error)}`);
46
+ }
47
+ const database = db;
62
48
  return {
63
49
  remember(runtimeIdentity, token) {
64
- tokens.set(runtimeIdentity, token);
65
- persist();
50
+ if (database === undefined)
51
+ return;
52
+ writeToken(database, options.parentSessionId, runtimeIdentity, token);
66
53
  },
67
54
  get(runtimeIdentity) {
68
- return tokens.get(runtimeIdentity);
55
+ if (database === undefined)
56
+ return undefined;
57
+ return readToken(database, options.parentSessionId, runtimeIdentity);
69
58
  },
70
59
  forget(runtimeIdentity) {
71
- if (tokens.delete(runtimeIdentity))
72
- persist();
60
+ if (database === undefined)
61
+ return;
62
+ deleteToken(database, options.parentSessionId, runtimeIdentity);
73
63
  },
74
64
  };
75
65
  }
76
- function readStoredTokens(file, diagnose) {
77
- let raw;
78
- try {
79
- raw = readFileSync(file, "utf8");
80
- }
81
- catch (error) {
82
- const code = error.code;
83
- if (code !== "ENOENT")
84
- diagnose(`could not read bridge tokens: ${errorMessage(error)}`);
85
- return [];
86
- }
87
- if (raw.trim() === "")
88
- return [];
89
- let parsed;
90
- try {
91
- parsed = JSON.parse(raw);
92
- }
93
- catch (error) {
94
- diagnose(`bridge token file is not valid JSON: ${errorMessage(error)}`);
95
- return [];
96
- }
97
- if (typeof parsed !== "object" || parsed === null)
98
- return [];
99
- return Object.entries(parsed).filter((entry) => typeof entry[1] === "string");
100
- }
101
66
  /**
102
67
  * Mints the runtime identity and its bridge token, remembers the token so the child's very first
103
68
  * hello can be authorized, and builds the one LaunchSpec for this presentation. The token travels
@@ -125,10 +90,11 @@ function planChildRuntime(record, tokens, presentation) {
125
90
  }
126
91
  /** Evidence first: the record names the runtime that was asked for even if starting it fails. */
127
92
  async function recordRuntimeEvidence(registry, record, plan) {
128
- await registry.update(record.subagentId, undefined, (current) => ({
129
- ...current,
130
- runtime: { runtimeIdentity: plan.runtimeIdentity, endpoint: plan.endpoint },
131
- }));
93
+ await commitLease(registry, record.subagentId, {
94
+ type: "runtime-bound",
95
+ runtimeIdentity: plan.runtimeIdentity,
96
+ endpoint: plan.endpoint,
97
+ });
132
98
  }
133
99
  /**
134
100
  * Starts one headless child Pi and waits for it to connect. Nothing is driven over its stdio: the
@@ -155,22 +121,22 @@ export async function launchChild(options) {
155
121
  options.diagnose?.(`bridge wait failed: ${errorMessage(error)}`);
156
122
  connected = false;
157
123
  }
158
- if (connected)
124
+ if (connected) {
125
+ await bindProcess(options.registry, record.subagentId, runtime.pid);
159
126
  return { handle: runtime };
127
+ }
160
128
  await runtime.terminate();
161
129
  if (runtime.alive) {
162
130
  // The process outlived termination, so it is not gone and the caller keeps the handle: it is
163
131
  // the only thing that can try again.
132
+ await bindProcess(options.registry, record.subagentId, runtime.pid);
164
133
  return {
165
134
  handle: runtime,
166
135
  failure: `Child ${record.subagentId} never connected to the parent bridge and did not stop (it may still be running)`,
167
136
  };
168
137
  }
169
138
  options.tokens.forget(plan.runtimeIdentity);
170
- await options.registry.update(record.subagentId, undefined, (current) => {
171
- const { runtime: _runtime, ...rest } = current;
172
- return rest;
173
- }, plan.runtimeIdentity);
139
+ await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" }, undefined, plan.runtimeIdentity);
174
140
  return { failure: `Child ${record.subagentId} never connected to the parent bridge` };
175
141
  }
176
142
  /**
@@ -191,8 +157,7 @@ export async function openChildPanel(options) {
191
157
  // failing to report it. The token is kept so a child that does come up can still be adopted,
192
158
  // and the caller is told the runtime is unconfirmed instead of that nothing was started.
193
159
  return {
194
- unconfirmed: true,
195
- failure: `Child ${record.subagentId} could not be opened in a ${options.host.kind} panel (${errorMessage(error)}); a panel may have been created, so stop the child before sending again`,
160
+ failure: `Child ${record.subagentId} could not be opened in a ${options.host.kind} panel (${errorMessage(error)}); a panel may have been created, so close it if it is still there`,
196
161
  };
197
162
  }
198
163
  let connected;
@@ -203,25 +168,24 @@ export async function openChildPanel(options) {
203
168
  options.diagnose?.(`bridge wait failed: ${errorMessage(error)}`);
204
169
  connected = false;
205
170
  }
206
- if (connected)
171
+ if (connected) {
172
+ await bindPanel(options.registry, record.subagentId, options.host.kind, attachment.identity.attachmentId);
207
173
  return { handle: attachment };
208
- // The child never dialed in, so the panel is closed again — and only a host that confirms it is
209
- // gone counts. While it may still run, the caller keeps the handle and the token: the handle is
210
- // what stops a second runtime from being started for the same session, and the token is what lets
211
- // a child that does come up be adopted instead of lingering as a process nobody owns.
174
+ }
175
+ // The child never dialed in, so the panel is closed again. A host that cannot confirm it is gone
176
+ // keeps the handle, so stop can try the close again, and the token, so a child that does come up
177
+ // can still be adopted. The handle does not block a later send.
212
178
  const cleanup = await attachment.cleanup();
213
179
  const observed = await attachment.observe();
214
180
  if (!(observed.known && !observed.alive)) {
181
+ await bindPanel(options.registry, record.subagentId, options.host.kind, attachment.identity.attachmentId);
215
182
  return {
216
183
  handle: attachment,
217
184
  failure: `Child ${record.subagentId} never connected to the parent bridge and its panel could not be closed (${cleanup.stderr || "its state could not be observed"}); close it manually`,
218
185
  };
219
186
  }
220
187
  options.tokens.forget(plan.runtimeIdentity);
221
- await options.registry.update(record.subagentId, undefined, (current) => {
222
- const { runtime: _runtime, ...rest } = current;
223
- return rest;
224
- }, plan.runtimeIdentity);
188
+ await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" }, undefined, plan.runtimeIdentity);
225
189
  return { failure: `Child ${record.subagentId} never connected to the parent bridge` };
226
190
  }
227
191
  /** Creates the runtime directory if needed; the socket itself is 0600 in a 0700 directory. */
@@ -233,3 +197,17 @@ export async function prepareRuntimeDirectory(diagnose) {
233
197
  });
234
198
  return directory;
235
199
  }
200
+ async function bindProcess(registry, id, pid) {
201
+ if (pid === undefined)
202
+ return;
203
+ await commitLease(registry, id, {
204
+ type: "attachment-bound",
205
+ attachment: { kind: "process", pid },
206
+ });
207
+ }
208
+ async function bindPanel(registry, id, host, attachmentId) {
209
+ await commitLease(registry, id, {
210
+ type: "attachment-bound",
211
+ attachment: { kind: "panel", host, id: attachmentId },
212
+ });
213
+ }