@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.
- package/README.md +5 -5
- package/dist/child-control.d.ts +1 -1
- package/dist/child-control.js +89 -26
- package/dist/child-process.d.ts +2 -0
- package/dist/child-process.js +24 -12
- package/dist/domain.d.ts +15 -0
- package/dist/extension.d.ts +3 -4
- package/dist/extension.js +66 -79
- package/dist/host-adapter.d.ts +19 -0
- package/dist/host-adapter.js +139 -0
- package/dist/info.js +32 -6
- package/dist/lease.d.ts +83 -0
- package/dist/lease.js +239 -0
- package/dist/mailbox.d.ts +46 -0
- package/dist/mailbox.js +368 -0
- package/dist/manager.d.ts +17 -19
- package/dist/manager.js +301 -427
- package/dist/process-lock.d.ts +11 -0
- package/dist/process-lock.js +55 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.js +1 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +80 -77
- package/dist/rpc-events.d.ts +1 -1
- package/dist/rpc-events.js +2 -0
- package/dist/runtime.d.ts +8 -21
- package/dist/runtime.js +61 -83
- package/dist/state-db.d.ts +52 -0
- package/dist/state-db.js +209 -0
- package/dist/state.d.ts +12 -0
- package/dist/state.js +107 -21
- package/dist/tool-presentation.d.ts +2 -5
- package/dist/tool-presentation.js +38 -5
- package/dist/tools.js +3 -2
- package/package.json +6 -6
- package/dist/bridge-client.d.ts +0 -60
- package/dist/bridge-client.js +0 -459
- package/dist/bridge-server.d.ts +0 -72
- package/dist/bridge-server.js +0 -490
|
@@ -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
|
+
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
import { isLaunchValueSource, isPersistenceState, isPresentation, isSessionId, isSubagentIntent, isSubagentState, isThinkingLevel, normalizeSubagentState,
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
367
|
-
|
|
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
|
|
405
|
-
const
|
|
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
|
|
409
|
-
return (
|
|
417
|
+
async get(id) {
|
|
418
|
+
return load(id);
|
|
410
419
|
},
|
|
411
|
-
async list(
|
|
412
|
-
const records =
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
}
|
package/dist/rpc-events.d.ts
CHANGED
|
@@ -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;
|
package/dist/rpc-events.js
CHANGED
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(
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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.
|
|
57
|
-
*
|
|
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 {
|
|
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(
|
|
26
|
-
return
|
|
26
|
+
export function parentBridgeEndpoint(_parentSessionId) {
|
|
27
|
+
return defaultStatePath();
|
|
27
28
|
}
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
38
|
-
*
|
|
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 ??
|
|
38
|
+
const file = options.file ?? defaultStatePath();
|
|
44
39
|
const diagnose = options.diagnose ?? (() => { });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
65
|
-
|
|
50
|
+
if (database === undefined)
|
|
51
|
+
return;
|
|
52
|
+
writeToken(database, options.parentSessionId, runtimeIdentity, token);
|
|
66
53
|
},
|
|
67
54
|
get(runtimeIdentity) {
|
|
68
|
-
|
|
55
|
+
if (database === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
return readToken(database, options.parentSessionId, runtimeIdentity);
|
|
69
58
|
},
|
|
70
59
|
forget(runtimeIdentity) {
|
|
71
|
-
if (
|
|
72
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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
|
|
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
|
+
}
|