@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.
- package/README.md +35 -20
- package/dist/agent-resolver.d.ts +9 -0
- package/dist/agent-resolver.js +278 -51
- package/dist/builtin-agents.js +50 -37
- package/dist/child-bridge.d.ts +0 -8
- package/dist/child-bridge.js +60 -43
- package/dist/child-control.d.ts +6 -4
- package/dist/child-control.js +251 -47
- package/dist/child-process.d.ts +2 -0
- package/dist/child-process.js +24 -12
- package/dist/commands.d.ts +1 -4
- package/dist/commands.js +1 -52
- package/dist/domain.d.ts +27 -12
- package/dist/domain.js +2 -1
- package/dist/extension.d.ts +7 -6
- package/dist/extension.js +96 -112
- package/dist/host-adapter.d.ts +19 -0
- package/dist/host-adapter.js +140 -1
- package/dist/info.d.ts +9 -0
- package/dist/info.js +80 -0
- package/dist/launch-spec.d.ts +0 -1
- package/dist/launch-spec.js +38 -18
- 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 +20 -20
- package/dist/manager.js +421 -398
- 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 +99 -82
- package/dist/rpc-events.d.ts +1 -1
- package/dist/rpc-events.js +2 -1
- package/dist/runtime.d.ts +8 -21
- package/dist/runtime.js +61 -83
- package/dist/session-bootstrap.d.ts +2 -1
- package/dist/session-bootstrap.js +22 -14
- package/dist/state-db.d.ts +52 -0
- package/dist/state-db.js +209 -0
- package/dist/state.d.ts +25 -0
- package/dist/state.js +166 -37
- package/dist/status.d.ts +14 -0
- package/dist/status.js +36 -0
- package/dist/tool-activation.d.ts +3 -4
- package/dist/tool-activation.js +13 -215
- package/dist/tool-presentation.d.ts +14 -0
- package/dist/tool-presentation.js +193 -0
- package/dist/tools.d.ts +6 -1
- package/dist/tools.js +134 -180
- package/package.json +6 -6
- package/dist/bridge-client.d.ts +0 -55
- package/dist/bridge-client.js +0 -453
- package/dist/bridge-server.d.ts +0 -72
- package/dist/bridge-server.js +0 -490
- package/dist/session-fork.d.ts +0 -16
- package/dist/session-fork.js +0 -67
- package/dist/widget.d.ts +0 -12
- 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 {
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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("
|
|
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("
|
|
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 (
|
|
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;
|
package/dist/state-db.js
ADDED
|
@@ -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;
|