@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
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
+ }
@@ -1,6 +1,8 @@
1
1
  import type { ModelRegistryLike, ParentAgentDefaults, SkillCatalogEntry } from "./agent-resolver.js";
2
2
  import type { EffectiveLaunchConfig, PersistenceState, PiInvocation, Presentation, SpawnSubagentInput, SubagentRecord } from "./domain.js";
3
3
  import type { SubagentRegistry } from "./registry.js";
4
+ /** Reads all JSON entries from a session file on disk. */
5
+ export declare function readSessionJsonlEntries(sessionPath: string): Promise<unknown[]>;
4
6
  export interface SessionPlacement {
5
7
  readonly sessionId: string;
6
8
  readonly sessionDir: string;
@@ -62,7 +64,6 @@ export interface ResolveSubagentLaunchOptions {
62
64
  readonly existingSubagentIds?: readonly string[];
63
65
  readonly enforceEnabled?: boolean;
64
66
  readonly sessionPath?: string;
65
- readonly referencedSessionPath?: string;
66
67
  }
67
68
  /**
68
69
  * One resolution chain for a spawn: agent definition + explicit parent defaults
@@ -1,13 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { open, readdir, stat } from "node:fs/promises";
2
+ import { open, readdir, readFile, stat } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
- import { SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
5
5
  import { isRecord } from "@hheei/pi-ext-core";
6
6
  import { resolveAgent } from "./agent-resolver.js";
7
7
  import { builtinAgentToolProblem } from "./builtin-agents.js";
8
8
  import { isSessionId } from "./domain.js";
9
9
  import { assembleChildPrompt, resolveBridgeExtensionPath, resolvePiInvocation, } from "./launch-spec.js";
10
- import { formatSessionReferencePrompt } from "./session-fork.js";
10
+ /** Reads all JSON entries from a session file on disk. */
11
+ export async function readSessionJsonlEntries(sessionPath) {
12
+ const raw = await readFile(sessionPath, "utf8");
13
+ const lines = raw.split("\n").filter((line) => line.trim() !== "");
14
+ const entries = [];
15
+ for (const line of lines) {
16
+ try {
17
+ entries.push(JSON.parse(line));
18
+ }
19
+ catch {
20
+ // ignore trailing malformed line
21
+ }
22
+ }
23
+ return entries;
24
+ }
11
25
  /** Bounded read for a session header; the first line holds the whole identity of the file. */
12
26
  const HEADER_READ_BYTES = 64 * 1024;
13
27
  /**
@@ -151,10 +165,10 @@ export function createSubagentId(existingIds) {
151
165
  export async function resolveSubagentLaunch(options) {
152
166
  const task = options.input.task.trim();
153
167
  if (task === "")
154
- throw new Error("spawn_agent requires a non-empty task");
168
+ throw new Error("agent_spawn requires a non-empty task");
155
169
  const name = options.input.agent?.trim();
156
170
  if (name === undefined || name === "") {
157
- throw new Error("spawn_agent requires an explicit agent name; V1 has no built-in default agent");
171
+ throw new Error("agent_spawn requires an explicit agent name; V1 has no built-in default agent");
158
172
  }
159
173
  const cwd = resolve(options.input.cwd?.trim() || options.cwd);
160
174
  const requestedTitle = options.input.title?.trim();
@@ -174,12 +188,9 @@ export async function resolveSubagentLaunch(options) {
174
188
  ...(options.onWarning === undefined ? {} : { onWarning: options.onWarning }),
175
189
  });
176
190
  if (options.enforceEnabled === true && policy.enabled === false) {
177
- throw new Error(`Agent "${policy.agent.name}" is disabled: no model is specified in user configuration (~/.pi/agent/agents/ or .pi/agents/)`);
178
- }
179
- let instructions = policy.agent.instructions;
180
- if (options.referencedSessionPath !== undefined && options.referencedSessionPath.trim() !== "") {
181
- instructions = `${instructions.trim()}\n\n${formatSessionReferencePrompt(options.referencedSessionPath)}`;
191
+ throw new Error(`Agent "${policy.agent.name}" is disabled: no model is specified in user configuration (${join(getAgentDir(), "agents")}/ or .pi/agents/)`);
182
192
  }
193
+ const instructions = policy.agent.instructions;
183
194
  return Object.freeze({
184
195
  subagentId,
185
196
  invocation: options.invocation ?? resolvePiInvocation(),
@@ -187,9 +198,6 @@ export async function resolveSubagentLaunch(options) {
187
198
  sessionId,
188
199
  sessionDir: resolveSubagentSessionDir(cwd),
189
200
  ...(options.sessionPath === undefined ? {} : { sessionPath: options.sessionPath }),
190
- ...(options.referencedSessionPath === undefined
191
- ? {}
192
- : { referencedSessionPath: options.referencedSessionPath }),
193
201
  agent: policy.agent,
194
202
  model: policy.model,
195
203
  thinking: policy.thinking,
@@ -197,9 +205,9 @@ export async function resolveSubagentLaunch(options) {
197
205
  excludeTools: policy.excludeTools,
198
206
  extensions: policy.extensions,
199
207
  skills: policy.skills,
208
+ mcp: policy.mcp,
200
209
  prompt: assembleChildPrompt(instructions),
201
210
  bridgeExtensionPath,
202
- interactive: policy.interactive,
203
211
  ...(policy.codemodeOnly ? { codemodeOnly: true } : {}),
204
212
  ...(title === undefined ? {} : { title }),
205
213
  });
@@ -0,0 +1,52 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ /** The one durable file for child records, commands, and tokens. */
3
+ export declare function defaultStatePath(): string;
4
+ export declare function openStateDb(filePath: string): DatabaseSync;
5
+ export declare function withWrite<T>(db: DatabaseSync, run: () => T): T;
6
+ export interface StoredCommand {
7
+ readonly id: string;
8
+ readonly parentSessionId: string;
9
+ readonly subagentId: string;
10
+ readonly direction: "to_child" | "to_parent";
11
+ readonly operation: string;
12
+ readonly payload: string;
13
+ readonly status: string;
14
+ readonly result: string | null;
15
+ readonly errorCode: string | null;
16
+ readonly error: string | null;
17
+ }
18
+ export declare function insertChild(db: DatabaseSync, parentSessionId: string, subagentId: string, revision: number, record: string): boolean;
19
+ export declare function readChild(db: DatabaseSync, parentSessionId: string, subagentId: string): {
20
+ readonly revision: number;
21
+ readonly record: string;
22
+ } | undefined;
23
+ export declare function listChildRecords(db: DatabaseSync, parentSessionId: string): readonly string[];
24
+ export declare function replaceChild(db: DatabaseSync, parentSessionId: string, subagentId: string, expectedRevision: number | undefined, revision: number, record: string): boolean;
25
+ export declare function readMeta(db: DatabaseSync, parentSessionId: string, key: string): string | undefined;
26
+ export declare function writeMeta(db: DatabaseSync, parentSessionId: string, key: string, value: string): void;
27
+ export declare function enqueueCommand(db: DatabaseSync, command: {
28
+ readonly id: string;
29
+ readonly parentSessionId: string;
30
+ readonly subagentId: string;
31
+ readonly direction: "to_child" | "to_parent";
32
+ readonly operation: string;
33
+ readonly payload: string;
34
+ readonly createdAt: number;
35
+ }): void;
36
+ export declare function hasPendingCommand(db: DatabaseSync, parentSessionId: string, direction: "to_child" | "to_parent", subagentId?: string): boolean;
37
+ export declare function claimCommand(db: DatabaseSync, parentSessionId: string, subagentId: string, direction: "to_child" | "to_parent"): StoredCommand | undefined;
38
+ export declare function claimParentCommands(db: DatabaseSync, parentSessionId: string): readonly StoredCommand[];
39
+ export declare function requeueAbandonedParentCommands(db: DatabaseSync, parentSessionId: string): void;
40
+ export declare function deleteCommand(db: DatabaseSync, id: string): void;
41
+ export declare function readCommand(db: DatabaseSync, id: string): StoredCommand | undefined;
42
+ export declare function finishCommand(db: DatabaseSync, id: string, outcome: {
43
+ readonly status: "done";
44
+ readonly result: string;
45
+ } | {
46
+ readonly status: "failed";
47
+ readonly errorCode: string;
48
+ readonly error: string;
49
+ }): void;
50
+ export declare function readToken(db: DatabaseSync, parentSessionId: string, runtimeIdentity: string): string | undefined;
51
+ export declare function writeToken(db: DatabaseSync, parentSessionId: string, runtimeIdentity: string, token: string): void;
52
+ export declare function deleteToken(db: DatabaseSync, parentSessionId: string, runtimeIdentity: string): void;
@@ -0,0 +1,209 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ /** The one durable file for child records, commands, and tokens. */
6
+ export function defaultStatePath() {
7
+ return join(getAgentDir(), "pi-subagents", "state.sqlite");
8
+ }
9
+ const SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS children (
11
+ parent_session_id TEXT NOT NULL,
12
+ subagent_id TEXT NOT NULL,
13
+ revision INTEGER NOT NULL,
14
+ record TEXT NOT NULL,
15
+ PRIMARY KEY (parent_session_id, subagent_id)
16
+ );
17
+ CREATE TABLE IF NOT EXISTS session_meta (
18
+ parent_session_id TEXT NOT NULL,
19
+ key TEXT NOT NULL,
20
+ value TEXT NOT NULL,
21
+ PRIMARY KEY (parent_session_id, key)
22
+ );
23
+ CREATE TABLE IF NOT EXISTS commands (
24
+ id TEXT PRIMARY KEY,
25
+ parent_session_id TEXT NOT NULL,
26
+ subagent_id TEXT NOT NULL,
27
+ direction TEXT NOT NULL,
28
+ operation TEXT NOT NULL,
29
+ payload TEXT NOT NULL,
30
+ status TEXT NOT NULL,
31
+ result TEXT,
32
+ error_code TEXT,
33
+ error TEXT,
34
+ created_at INTEGER NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS commands_pending
37
+ ON commands (parent_session_id, subagent_id, direction, status, created_at);
38
+ CREATE TABLE IF NOT EXISTS tokens (
39
+ parent_session_id TEXT NOT NULL,
40
+ runtime_identity TEXT NOT NULL,
41
+ token TEXT NOT NULL,
42
+ PRIMARY KEY (parent_session_id, runtime_identity)
43
+ );
44
+ `;
45
+ const connections = new Map();
46
+ export function openStateDb(filePath) {
47
+ const cached = connections.get(filePath);
48
+ if (cached !== undefined)
49
+ return cached;
50
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
51
+ const db = new DatabaseSync(filePath);
52
+ db.exec("PRAGMA journal_mode = WAL");
53
+ db.exec("PRAGMA busy_timeout = 5000");
54
+ db.exec(SCHEMA);
55
+ db.exec("DROP TABLE IF EXISTS presence");
56
+ chmodSync(filePath, 0o600);
57
+ connections.set(filePath, db);
58
+ return db;
59
+ }
60
+ export function withWrite(db, run) {
61
+ db.exec("BEGIN IMMEDIATE");
62
+ try {
63
+ const value = run();
64
+ db.exec("COMMIT");
65
+ return value;
66
+ }
67
+ catch (error) {
68
+ try {
69
+ db.exec("ROLLBACK");
70
+ }
71
+ catch {
72
+ // The original error is the one the caller handles.
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ function commandFromRow(row) {
78
+ return {
79
+ id: row.id,
80
+ parentSessionId: row.parent_session_id,
81
+ subagentId: row.subagent_id,
82
+ direction: row.direction === "to_parent" ? "to_parent" : "to_child",
83
+ operation: row.operation,
84
+ payload: row.payload,
85
+ status: row.status,
86
+ result: row.result,
87
+ errorCode: row.error_code,
88
+ error: row.error,
89
+ };
90
+ }
91
+ const COMMAND_COLUMNS = "id, parent_session_id, subagent_id, direction, operation, payload, status, result, error_code, error";
92
+ export function insertChild(db, parentSessionId, subagentId, revision, record) {
93
+ const result = db
94
+ .prepare(`INSERT INTO children (parent_session_id, subagent_id, revision, record)
95
+ VALUES (?, ?, ?, ?)
96
+ ON CONFLICT DO NOTHING`)
97
+ .run(parentSessionId, subagentId, revision, record);
98
+ return Number(result.changes) === 1;
99
+ }
100
+ export function readChild(db, parentSessionId, subagentId) {
101
+ const row = db
102
+ .prepare("SELECT revision, record FROM children WHERE parent_session_id = ? AND subagent_id = ?")
103
+ .get(parentSessionId, subagentId);
104
+ return row;
105
+ }
106
+ export function listChildRecords(db, parentSessionId) {
107
+ const rows = db
108
+ .prepare("SELECT record FROM children WHERE parent_session_id = ? ORDER BY record")
109
+ .all(parentSessionId);
110
+ return rows.map((row) => row.record);
111
+ }
112
+ export function replaceChild(db, parentSessionId, subagentId, expectedRevision, revision, record) {
113
+ const result = expectedRevision === undefined
114
+ ? db
115
+ .prepare(`UPDATE children SET revision = ?, record = ?
116
+ WHERE parent_session_id = ? AND subagent_id = ?`)
117
+ .run(revision, record, parentSessionId, subagentId)
118
+ : db
119
+ .prepare(`UPDATE children SET revision = ?, record = ?
120
+ WHERE parent_session_id = ? AND subagent_id = ? AND revision = ?`)
121
+ .run(revision, record, parentSessionId, subagentId, expectedRevision);
122
+ return Number(result.changes) === 1;
123
+ }
124
+ export function readMeta(db, parentSessionId, key) {
125
+ const row = db
126
+ .prepare("SELECT value FROM session_meta WHERE parent_session_id = ? AND key = ?")
127
+ .get(parentSessionId, key);
128
+ return row?.value;
129
+ }
130
+ export function writeMeta(db, parentSessionId, key, value) {
131
+ db.prepare(`INSERT INTO session_meta (parent_session_id, key, value) VALUES (?, ?, ?)
132
+ ON CONFLICT (parent_session_id, key) DO UPDATE SET value = excluded.value`).run(parentSessionId, key, value);
133
+ }
134
+ export function enqueueCommand(db, command) {
135
+ db.prepare(`INSERT INTO commands (
136
+ id, parent_session_id, subagent_id, direction, operation, payload, status, created_at
137
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`).run(command.id, command.parentSessionId, command.subagentId, command.direction, command.operation, command.payload, command.createdAt);
138
+ }
139
+ export function hasPendingCommand(db, parentSessionId, direction, subagentId) {
140
+ const row = subagentId === undefined
141
+ ? db
142
+ .prepare(`SELECT 1 AS hit FROM commands
143
+ WHERE parent_session_id = ? AND direction = ? AND status = 'pending'
144
+ LIMIT 1`)
145
+ .get(parentSessionId, direction)
146
+ : db
147
+ .prepare(`SELECT 1 AS hit FROM commands
148
+ WHERE parent_session_id = ? AND subagent_id = ? AND direction = ? AND status = 'pending'
149
+ LIMIT 1`)
150
+ .get(parentSessionId, subagentId, direction);
151
+ return row !== undefined;
152
+ }
153
+ export function claimCommand(db, parentSessionId, subagentId, direction) {
154
+ const row = db
155
+ .prepare(`UPDATE commands SET status = 'working'
156
+ WHERE id = (
157
+ SELECT id FROM commands
158
+ WHERE parent_session_id = ? AND subagent_id = ? AND direction = ? AND status = 'pending'
159
+ ORDER BY created_at
160
+ LIMIT 1
161
+ )
162
+ RETURNING ${COMMAND_COLUMNS}`)
163
+ .get(parentSessionId, subagentId, direction);
164
+ return row === undefined ? undefined : commandFromRow(row);
165
+ }
166
+ export function claimParentCommands(db, parentSessionId) {
167
+ const rows = db
168
+ .prepare(`UPDATE commands SET status = 'working'
169
+ WHERE id IN (
170
+ SELECT id FROM commands
171
+ WHERE parent_session_id = ? AND direction = 'to_parent' AND status = 'pending'
172
+ ORDER BY created_at
173
+ LIMIT 20
174
+ )
175
+ RETURNING ${COMMAND_COLUMNS}`)
176
+ .all(parentSessionId);
177
+ return rows.map(commandFromRow);
178
+ }
179
+ export function requeueAbandonedParentCommands(db, parentSessionId) {
180
+ db.prepare(`UPDATE commands SET status = 'pending'
181
+ WHERE parent_session_id = ? AND direction = 'to_parent' AND status = 'working'`).run(parentSessionId);
182
+ }
183
+ export function deleteCommand(db, id) {
184
+ db.prepare("DELETE FROM commands WHERE id = ?").run(id);
185
+ }
186
+ export function readCommand(db, id) {
187
+ const row = db.prepare(`SELECT ${COMMAND_COLUMNS} FROM commands WHERE id = ?`).get(id);
188
+ return row === undefined ? undefined : commandFromRow(row);
189
+ }
190
+ export function finishCommand(db, id, outcome) {
191
+ if (outcome.status === "done") {
192
+ db.prepare("UPDATE commands SET status = 'done', result = ? WHERE id = ?").run(outcome.result, id);
193
+ return;
194
+ }
195
+ db.prepare("UPDATE commands SET status = 'failed', error_code = ?, error = ? WHERE id = ?").run(outcome.errorCode, outcome.error, id);
196
+ }
197
+ export function readToken(db, parentSessionId, runtimeIdentity) {
198
+ const row = db
199
+ .prepare("SELECT token FROM tokens WHERE parent_session_id = ? AND runtime_identity = ?")
200
+ .get(parentSessionId, runtimeIdentity);
201
+ return row?.token;
202
+ }
203
+ export function writeToken(db, parentSessionId, runtimeIdentity, token) {
204
+ db.prepare(`INSERT INTO tokens (parent_session_id, runtime_identity, token) VALUES (?, ?, ?)
205
+ ON CONFLICT (parent_session_id, runtime_identity) DO UPDATE SET token = excluded.token`).run(parentSessionId, runtimeIdentity, token);
206
+ }
207
+ export function deleteToken(db, parentSessionId, runtimeIdentity) {
208
+ db.prepare("DELETE FROM tokens WHERE parent_session_id = ? AND runtime_identity = ?").run(parentSessionId, runtimeIdentity);
209
+ }
package/dist/state.d.ts CHANGED
@@ -6,9 +6,17 @@ export interface StateSnapshot {
6
6
  readonly activeTool?: string;
7
7
  readonly usage: UsageSummary;
8
8
  }
9
+ export interface TranscriptFacts {
10
+ readonly usage: UsageSummary;
11
+ readonly summary?: string;
12
+ readonly stopReason?: string;
13
+ readonly interrupted?: string;
14
+ readonly containsInput: boolean;
15
+ }
9
16
  export interface StateProjector {
10
17
  applyEvent(event: unknown): StateSnapshot;
11
18
  rebuild(entries: readonly unknown[]): StateSnapshot;
19
+ applyTranscriptFacts(facts: TranscriptFacts): StateSnapshot;
12
20
  /**
13
21
  * Puts the projected state back in step with a state the child itself reported. Without it a
14
22
  * transcript that still ends in a tool result keeps projecting "running" over a child that has
@@ -19,4 +27,21 @@ export interface StateProjector {
19
27
  }
20
28
  export declare function summarizeCurrentBranch(entries: readonly unknown[]): string | undefined;
21
29
  export declare function aggregateUsage(entries: readonly unknown[]): UsageSummary;
30
+ /** What the parent projects from a session. The transcript itself never crosses the bridge. */
31
+ export declare function transcriptFacts(entries: readonly unknown[], expectedInput?: string): TranscriptFacts;
32
+ /** A mailbox answer is untrusted. Anything that is not these fields is not facts. */
33
+ export declare function parseTranscriptFacts(value: unknown): TranscriptFacts | undefined;
34
+ /** The phase a streaming assistant update is in: what the parent shows as the child's activity. */
35
+ export type AssistantPhaseKind = "thinking" | "generating" | "toolcall";
36
+ export interface AssistantPhase {
37
+ readonly kind: AssistantPhaseKind;
38
+ /** The tool being streamed, when the phase is a tool call. */
39
+ readonly toolName?: string;
40
+ }
41
+ /**
42
+ * Categorizes a streaming assistant update into the phase the parent projects. The child throttles
43
+ * duplicate deltas with the same answer, so both ends of the bridge agree on what "still thinking"
44
+ * means instead of each re-deriving it from a different subset of the event.
45
+ */
46
+ export declare function assistantUpdatePhase(assistantMessageEvent: unknown, message: unknown): AssistantPhase | undefined;
22
47
  export declare function createStateProjector(initialState?: SubagentState): StateProjector;