pi-crew 0.9.61 → 0.9.64
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/CHANGELOG.md +99 -0
- package/README.md +1 -0
- package/agents/critic.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +256 -81
- package/package.json +5 -2
- package/src/agents/agent-config.ts +4 -0
- package/src/agents/agent-serializer.ts +1 -0
- package/src/agents/discover-agents.ts +8 -0
- package/src/config/role-tools.ts +49 -1
- package/src/extension/notification-router.ts +25 -0
- package/src/extension/registration/lifecycle-handlers.ts +47 -6
- package/src/extension/registration/lifecycle.ts +13 -7
- package/src/prompt/prompt-runtime.ts +6 -0
- package/src/prompt/scratchpad-lifecycle.ts +605 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
- package/src/runtime/child-pi/child-pi.ts +5 -0
- package/src/runtime/live-session/live-session-runtime.ts +12 -1
- package/src/runtime/model/pi-args.ts +1 -1
- package/src/runtime/model/session-model.ts +76 -1
- package/src/runtime/recovery/crash-recovery.ts +1 -1
- package/src/runtime/scratchpad/README.md +184 -0
- package/src/runtime/scratchpad/engine.ts +610 -0
- package/src/runtime/scratchpad/guest.ts +360 -0
- package/src/runtime/scratchpad/index.ts +22 -0
- package/src/runtime/scratchpad/protocol.ts +88 -0
- package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
- package/src/runtime/scratchpad/transform.ts +363 -0
- package/src/runtime/task-runner/child-executor.ts +48 -31
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-rlm guest — Node port (spike).
|
|
3
|
+
*
|
|
4
|
+
* The persistent evaluator process for the pi-crew scratchpad spike. Owns the
|
|
5
|
+
* namespace and runs cells against it: each cell executes inside a `with`
|
|
6
|
+
* block over a proxy, so ordinary assignments become namespace entries and
|
|
7
|
+
* ordinary reads resolve against them. Writes are refused once the owning cell
|
|
8
|
+
* has been cancelled, which keeps a cancelled cell's still-running
|
|
9
|
+
* continuation from mutating state a later cell is using.
|
|
10
|
+
*
|
|
11
|
+
* Output is tagged with the cell that produced it; snapshot, restore and
|
|
12
|
+
* listing requests are served over the same protocol pipe.
|
|
13
|
+
*
|
|
14
|
+
* SPIKE deviations from pi-rlm's Bun guest:
|
|
15
|
+
* - NO Bun, NO Bun.$ guard, NO host bridge (rlm/tools handles). The spike
|
|
16
|
+
* only proves patterns 01 (namespace), 04 (transform), 05 (incremental
|
|
17
|
+
* bindings), 08 (snapshot) and 09 (revive) on Node.
|
|
18
|
+
* - bun:jsc serialize/deserialize -> node:v8 (serialize returns a Buffer in
|
|
19
|
+
* Node, so base64 is direct — no ArrayBuffer slicing).
|
|
20
|
+
* - Bun.inspect for cell results -> node:util inspect.
|
|
21
|
+
*
|
|
22
|
+
* Runs as: node --experimental-strip-types guest.ts (spawned by EngineManager)
|
|
23
|
+
*
|
|
24
|
+
* Protocol traffic leaves on fd 3 and carries a nonce, so cell output can be
|
|
25
|
+
* neither mistaken for nor forged into a protocol message.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
+
import { writeSync } from "node:fs";
|
|
30
|
+
import { createInterface } from "node:readline";
|
|
31
|
+
import { format, inspect } from "node:util";
|
|
32
|
+
import { deserialize, serialize } from "node:v8";
|
|
33
|
+
import { decodeMessage, encodeMessage, type GuestToHostMessage, type HostToGuestMessage, NONCE_ENV, PROTOCOL_FD } from "./protocol.ts";
|
|
34
|
+
import { transformCell } from "./transform.ts";
|
|
35
|
+
|
|
36
|
+
// ── identity: nonce + unguessable internal names ─────────────────────────────
|
|
37
|
+
// The nonce is removed from the environment immediately so cell code cannot
|
|
38
|
+
// read it back and forge protocol traffic on fd 3.
|
|
39
|
+
|
|
40
|
+
const NONCE = process.env[NONCE_ENV] ?? "";
|
|
41
|
+
delete process.env[NONCE_ENV];
|
|
42
|
+
if (!NONCE) {
|
|
43
|
+
writeSync(2, "pi-rlm guest started without a protocol nonce\n");
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SCOPE_NAME = `__rlm_scope_${NONCE}`;
|
|
48
|
+
const CTX_NAME = `__rlm_ctx_${NONCE}`;
|
|
49
|
+
const INTERNAL_NAMES = new Set([SCOPE_NAME, CTX_NAME]);
|
|
50
|
+
|
|
51
|
+
// A pipe fd can be non-blocking: writeSync may write partially or throw EAGAIN
|
|
52
|
+
// when the host has not drained yet. Loop until the whole frame is out, or a
|
|
53
|
+
// half-written line would corrupt the protocol stream.
|
|
54
|
+
const backoff = new Int32Array(new SharedArrayBuffer(4));
|
|
55
|
+
|
|
56
|
+
function writeAllSync(fd: number, text: string): void {
|
|
57
|
+
const buffer = Buffer.from(text, "utf8");
|
|
58
|
+
let offset = 0;
|
|
59
|
+
while (offset < buffer.length) {
|
|
60
|
+
try {
|
|
61
|
+
offset += writeSync(fd, buffer, offset, buffer.length - offset);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
64
|
+
if (code === "EAGAIN" || code === "EWOULDBLOCK") {
|
|
65
|
+
Atomics.wait(backoff, 0, 0, 1);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
|
|
69
|
+
try {
|
|
70
|
+
writeSync(2, "[guest] protocol pipe closed; exiting\n");
|
|
71
|
+
// biome-ignore lint/suspicious/noEmptyBlockStatements: exit path — nothing left to report to.
|
|
72
|
+
} catch {}
|
|
73
|
+
// The host closed the protocol pipe (killed or disposed this engine).
|
|
74
|
+
// Nothing left to report to; exit quietly instead of crashing with an
|
|
75
|
+
// uncaught error the host would surface as a spurious failure.
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function send(message: GuestToHostMessage): void {
|
|
84
|
+
writeAllSync(PROTOCOL_FD, encodeMessage(message, NONCE));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── namespace, cell context ──────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
type Namespace = Record<string, unknown>;
|
|
90
|
+
const namespace: Namespace = Object.create(null);
|
|
91
|
+
|
|
92
|
+
/** D6 per-var restore cap (guest-side, NIT-5): a single decoded var larger than
|
|
93
|
+
* this is rejected → failed[] (bounds v8.deserialize amplification). Default
|
|
94
|
+
* 256 KiB — documented in README; pinned by P2-T5. */
|
|
95
|
+
const MAX_RESTORE_VAR_BYTES = 256 * 1024;
|
|
96
|
+
|
|
97
|
+
interface CellContext {
|
|
98
|
+
cellId: string;
|
|
99
|
+
/** Set when this cell is aborted; its later writes are discarded. */
|
|
100
|
+
aborted: boolean;
|
|
101
|
+
result?: { value: unknown };
|
|
102
|
+
setResult(value: unknown): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cellStorage = new AsyncLocalStorage<CellContext>();
|
|
106
|
+
let activeCell: CellContext | undefined;
|
|
107
|
+
|
|
108
|
+
function makeCellContext(cellId: string): CellContext {
|
|
109
|
+
const ctx: CellContext = {
|
|
110
|
+
cellId,
|
|
111
|
+
aborted: false,
|
|
112
|
+
setResult(value: unknown) {
|
|
113
|
+
if (!ctx.aborted) ctx.result = { value };
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
return ctx;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makeScopeProxy(ctx: CellContext): Namespace {
|
|
120
|
+
return new Proxy(namespace, {
|
|
121
|
+
has(_target, key) {
|
|
122
|
+
// Only the wrapper's own parameters are hidden, so user names — including
|
|
123
|
+
// __-prefixed ones — resolve and persist normally.
|
|
124
|
+
if (typeof key !== "string") return false;
|
|
125
|
+
return !INTERNAL_NAMES.has(key);
|
|
126
|
+
},
|
|
127
|
+
get(target, key) {
|
|
128
|
+
if (typeof key !== "string") return undefined;
|
|
129
|
+
if (key in target) return target[key];
|
|
130
|
+
return (globalThis as Record<string, unknown>)[key];
|
|
131
|
+
},
|
|
132
|
+
set(target, key, value) {
|
|
133
|
+
// Writes from an aborted cell's orphaned continuation are dropped;
|
|
134
|
+
// writes from cells that are merely older are not.
|
|
135
|
+
if (typeof key === "string" && !ctx.aborted) target[key] = value;
|
|
136
|
+
return true;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── user output capture ──────────────────────────────────────────────────────
|
|
142
|
+
// In Node, console methods route through process.stdout.write, but replacing
|
|
143
|
+
// them directly keeps the attribution identical to pi-rlm's. AsyncLocalStorage
|
|
144
|
+
// keeps attribution correct for output emitted by an orphaned continuation
|
|
145
|
+
// after its cell was aborted.
|
|
146
|
+
|
|
147
|
+
function emit(name: "stdout" | "stderr", text: string): void {
|
|
148
|
+
const owner = cellStorage.getStore() ?? activeCell;
|
|
149
|
+
send({ type: "stream", cellId: owner?.cellId ?? "", name, chunk: text });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function captureWrite(name: "stdout" | "stderr") {
|
|
153
|
+
return (chunk: unknown, ...rest: unknown[]): boolean => {
|
|
154
|
+
const text = typeof chunk === "string" ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);
|
|
155
|
+
emit(name, text);
|
|
156
|
+
const callback = rest.find((r) => typeof r === "function") as (() => void) | undefined;
|
|
157
|
+
callback?.();
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
process.stdout.write = captureWrite("stdout") as typeof process.stdout.write;
|
|
163
|
+
process.stderr.write = captureWrite("stderr") as typeof process.stderr.write;
|
|
164
|
+
|
|
165
|
+
function consoleWriter(name: "stdout" | "stderr") {
|
|
166
|
+
return (...args: unknown[]): void => {
|
|
167
|
+
emit(name, `${format(...args)}\n`);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const consoleOut = consoleWriter("stdout");
|
|
172
|
+
const consoleErr = consoleWriter("stderr");
|
|
173
|
+
console.log = consoleOut;
|
|
174
|
+
console.info = consoleOut;
|
|
175
|
+
console.debug = consoleOut;
|
|
176
|
+
console.dir = consoleOut;
|
|
177
|
+
console.warn = consoleErr;
|
|
178
|
+
console.error = consoleErr;
|
|
179
|
+
console.trace = consoleErr;
|
|
180
|
+
|
|
181
|
+
// ── bootstrap bindings ───────────────────────────────────────────────────────
|
|
182
|
+
// pi-rlm mounts rlm/tools/Bun handles here. The spike has no host bridge, so
|
|
183
|
+
// nothing is installed — but the hook is kept (and re-run after restore) for
|
|
184
|
+
// fidelity with the port plan: a later phase wires real bindings here.
|
|
185
|
+
|
|
186
|
+
const INTERNAL_BINDINGS = new Map<string, unknown>();
|
|
187
|
+
|
|
188
|
+
function installBootstrapBindings(): void {
|
|
189
|
+
// Spike: no host bridge, no Bun — nothing to mount.
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
installBootstrapBindings();
|
|
193
|
+
|
|
194
|
+
// ── cell execution ───────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
// biome-ignore lint/suspicious/noEmptyBlockStatements: async arrow is only a constructor carrier.
|
|
197
|
+
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>;
|
|
198
|
+
|
|
199
|
+
const liveCells = new Map<string, CellContext>();
|
|
200
|
+
|
|
201
|
+
async function runCell(cellId: string, code: string): Promise<void> {
|
|
202
|
+
const ctx = makeCellContext(cellId);
|
|
203
|
+
activeCell = ctx;
|
|
204
|
+
liveCells.set(cellId, ctx);
|
|
205
|
+
|
|
206
|
+
let done: GuestToHostMessage;
|
|
207
|
+
try {
|
|
208
|
+
const { body } = transformCell(code, { ctxName: CTX_NAME });
|
|
209
|
+
// Sloppy-mode wrapper so `with` is legal (AsyncFunction bodies are always
|
|
210
|
+
// sloppy, even inside a strict ESM strip-types module); async for
|
|
211
|
+
// top-level await.
|
|
212
|
+
const wrapper = new AsyncFunction(SCOPE_NAME, CTX_NAME, `with (${SCOPE_NAME}) { ${body}\n }`);
|
|
213
|
+
await cellStorage.run(ctx, () => wrapper(makeScopeProxy(ctx), ctx));
|
|
214
|
+
done = {
|
|
215
|
+
type: "done",
|
|
216
|
+
cellId,
|
|
217
|
+
status: ctx.aborted ? "aborted" : "ok",
|
|
218
|
+
result: !ctx.aborted && ctx.result && ctx.result.value !== undefined ? inspect(ctx.result.value) : undefined,
|
|
219
|
+
};
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
222
|
+
done = {
|
|
223
|
+
type: "done",
|
|
224
|
+
cellId,
|
|
225
|
+
status: ctx.aborted ? "aborted" : "error",
|
|
226
|
+
error: { name: err.name, message: err.message, stack: (err.stack ?? "").split("\n") },
|
|
227
|
+
};
|
|
228
|
+
} finally {
|
|
229
|
+
if (activeCell === ctx) activeCell = undefined;
|
|
230
|
+
liveCells.delete(cellId);
|
|
231
|
+
}
|
|
232
|
+
send(done);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function abortCell(cellId: string): void {
|
|
236
|
+
const ctx = liveCells.get(cellId);
|
|
237
|
+
if (ctx) ctx.aborted = true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── snapshot / restore / names ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function snapshotNamespace(): { vars: Record<string, string>; failed: { name: string; reason: string }[] } {
|
|
243
|
+
const vars: Record<string, string> = {};
|
|
244
|
+
const failed: { name: string; reason: string }[] = [];
|
|
245
|
+
for (const [name, value] of Object.entries(namespace)) {
|
|
246
|
+
if (INTERNAL_BINDINGS.get(name) === value) continue;
|
|
247
|
+
try {
|
|
248
|
+
// node:v8 serialize returns a Buffer — base64 direct, no slicing.
|
|
249
|
+
// Functions/classes throw ("could not be cloned"); they land in
|
|
250
|
+
// `failed` instead of crashing the snapshot.
|
|
251
|
+
vars[name] = serialize(value).toString("base64");
|
|
252
|
+
} catch (error) {
|
|
253
|
+
failed.push({ name, reason: error instanceof Error ? error.message : String(error) });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { vars, failed };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function restoreNamespace(vars: Record<string, string>): {
|
|
260
|
+
restored: string[];
|
|
261
|
+
failed: { name: string; reason: string }[];
|
|
262
|
+
} {
|
|
263
|
+
const restored: string[] = [];
|
|
264
|
+
const failed: { name: string; reason: string }[] = [];
|
|
265
|
+
for (const [name, encoded] of Object.entries(vars)) {
|
|
266
|
+
// D4 (MAJOR-S2): a redacted secret arrives as the literal "***" (structural
|
|
267
|
+
// redaction in writeArtifact). base64 of a real value is never "***", so this
|
|
268
|
+
// special-case is collision-free — restore it as the placeholder so the model
|
|
269
|
+
// re-fetches, instead of failing (Buffer.from("***") → empty → deserialize
|
|
270
|
+
// throws → wrongly lands in failed).
|
|
271
|
+
if (encoded === "***") {
|
|
272
|
+
namespace[name] = "***";
|
|
273
|
+
restored.push(name);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const buffer = Buffer.from(encoded, "base64");
|
|
278
|
+
// D6 per-var cap (256 KiB) — bounds v8 amplification per decoded var.
|
|
279
|
+
if (buffer.length > MAX_RESTORE_VAR_BYTES) {
|
|
280
|
+
failed.push({ name, reason: `size:${buffer.length}>${MAX_RESTORE_VAR_BYTES}` });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// D13 (MINOR-S4): flat redaction can inject "***" INTO a valid base64 payload
|
|
284
|
+
// (bytes matching sk-/eyJ patterns) → decode yields a DIFFERENT buffer →
|
|
285
|
+
// silent corruption. Round-trip check: a clean base64 re-encodes identically.
|
|
286
|
+
if (buffer.toString("base64") !== encoded) {
|
|
287
|
+
failed.push({ name, reason: "base64-corrupt" });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
namespace[name] = deserialize(buffer);
|
|
291
|
+
restored.push(name);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
failed.push({ name, reason: error instanceof Error ? error.message : String(error) });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Bootstrap runs after restore: live handles would overwrite anything
|
|
297
|
+
// revived. The spike installs nothing, but the hook is kept for fidelity.
|
|
298
|
+
installBootstrapBindings();
|
|
299
|
+
return { restored, failed };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function listNames(): string[] {
|
|
303
|
+
return Object.keys(namespace).filter((name) => INTERNAL_BINDINGS.get(name) !== namespace[name]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── resilience ───────────────────────────────────────────────────────────────
|
|
307
|
+
// A throw from a detached task (setTimeout, a floating promise) would otherwise
|
|
308
|
+
// kill the process and take the whole namespace with it. Report it as stderr on
|
|
309
|
+
// the owning cell and keep the evaluator alive.
|
|
310
|
+
|
|
311
|
+
function reportStrayError(kind: string, error: unknown): void {
|
|
312
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
313
|
+
emit("stderr", `[${kind}] ${err.name}: ${err.message}\n`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
process.on("uncaughtException", (error) => reportStrayError("uncaught exception", error));
|
|
317
|
+
process.on("unhandledRejection", (reason) => reportStrayError("unhandled rejection", reason));
|
|
318
|
+
|
|
319
|
+
// ── message loop ─────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
const readline = createInterface({ input: process.stdin });
|
|
322
|
+
|
|
323
|
+
readline.on("line", (line) => {
|
|
324
|
+
const message = decodeMessage<HostToGuestMessage>(line, NONCE);
|
|
325
|
+
if (!message) return;
|
|
326
|
+
switch (message.type) {
|
|
327
|
+
case "run":
|
|
328
|
+
void runCell(message.cellId, message.code);
|
|
329
|
+
break;
|
|
330
|
+
case "abort":
|
|
331
|
+
abortCell(message.cellId);
|
|
332
|
+
break;
|
|
333
|
+
case "ping":
|
|
334
|
+
send({ type: "pong", id: message.id });
|
|
335
|
+
break;
|
|
336
|
+
case "snapshot": {
|
|
337
|
+
const { vars, failed } = snapshotNamespace();
|
|
338
|
+
send({ type: "snapshot_result", id: message.id, vars, failed });
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "restore": {
|
|
342
|
+
const { restored, failed } = restoreNamespace(message.vars);
|
|
343
|
+
send({ type: "restore_result", id: message.id, restored, failed });
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case "list_names":
|
|
347
|
+
send({ type: "names_result", id: message.id, names: listNames() });
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
readline.on("close", () => {
|
|
353
|
+
try {
|
|
354
|
+
writeSync(2, "[guest] stdin closed; exiting\n");
|
|
355
|
+
// biome-ignore lint/suspicious/noEmptyBlockStatements: best-effort write on teardown.
|
|
356
|
+
} catch {}
|
|
357
|
+
process.exit(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
send({ type: "ready" });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scratchpad spike — pi-rlm engine ported to Node.
|
|
3
|
+
*
|
|
4
|
+
* Barrel re-exporting the host engine and the shared transform/protocol
|
|
5
|
+
* surface. The spike proves patterns 01 (namespace), 04 (transform), 05
|
|
6
|
+
* (incremental bindings), 08 (snapshot) and 09 (revive) run on Node without
|
|
7
|
+
* Bun. See README.md in this directory for how to run and what was proven.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
EngineExecuteError,
|
|
12
|
+
EngineOptions,
|
|
13
|
+
ExecuteOptions,
|
|
14
|
+
ExecuteResult,
|
|
15
|
+
RestoreResult,
|
|
16
|
+
SnapshotResult,
|
|
17
|
+
} from "./engine.ts";
|
|
18
|
+
export { EngineManager } from "./engine.ts";
|
|
19
|
+
export type { GuestToHostMessage, HostToGuestMessage } from "./protocol.ts";
|
|
20
|
+
export { decodeMessage, ENVELOPE_KEY, encodeMessage, NONCE_ENV, PROTOCOL_FD } from "./protocol.ts";
|
|
21
|
+
export type { TransformedCell, TransformOptions } from "./transform.ts";
|
|
22
|
+
export { transformCell } from "./transform.ts";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between EngineManager (host) and the guest process.
|
|
3
|
+
*
|
|
4
|
+
* Transport: line-delimited JSON on a dedicated pipe (fd 3), never on stdout.
|
|
5
|
+
*
|
|
6
|
+
* Two properties make the channel trustworthy, and both are load-bearing:
|
|
7
|
+
*
|
|
8
|
+
* 1. Separation. The guest's stdout and stderr carry only user output, so a
|
|
9
|
+
* cell that prints JSON cannot be parsed as protocol traffic. Sharing one
|
|
10
|
+
* channel would let ordinary output alter the engine's view of a cell.
|
|
11
|
+
* 2. Authentication. Every envelope carries a nonce the host mints at spawn
|
|
12
|
+
* and the guest erases from its environment before running any cell. Code
|
|
13
|
+
* inside a cell cannot recover it, so even a deliberate write to fd 3
|
|
14
|
+
* cannot forge a message.
|
|
15
|
+
*
|
|
16
|
+
* Together they make a cell unable to report its own outcome — it can only run.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface HostToGuest {
|
|
20
|
+
run: { type: "run"; cellId: string; code: string };
|
|
21
|
+
abort: { type: "abort"; cellId: string };
|
|
22
|
+
ping: { type: "ping"; id: string };
|
|
23
|
+
host_reply: {
|
|
24
|
+
type: "host_reply";
|
|
25
|
+
id: string;
|
|
26
|
+
status: "ok" | "error";
|
|
27
|
+
payload?: Record<string, unknown>;
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
snapshot: { type: "snapshot"; id: string };
|
|
31
|
+
restore: { type: "restore"; id: string; vars: Record<string, string> };
|
|
32
|
+
list_names: { type: "list_names"; id: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type HostToGuestMessage = HostToGuest[keyof HostToGuest];
|
|
36
|
+
|
|
37
|
+
export interface GuestToHost {
|
|
38
|
+
ready: { type: "ready" };
|
|
39
|
+
stream: { type: "stream"; cellId: string; name: "stdout" | "stderr"; chunk: string };
|
|
40
|
+
done: {
|
|
41
|
+
type: "done";
|
|
42
|
+
cellId: string;
|
|
43
|
+
status: "ok" | "error" | "aborted";
|
|
44
|
+
result?: string;
|
|
45
|
+
error?: { name: string; message: string; stack: string[] };
|
|
46
|
+
};
|
|
47
|
+
pong: { type: "pong"; id: string };
|
|
48
|
+
host_request: { type: "host_request"; id: string; requestType: string; payload: Record<string, unknown> };
|
|
49
|
+
snapshot_result: {
|
|
50
|
+
type: "snapshot_result";
|
|
51
|
+
id: string;
|
|
52
|
+
vars: Record<string, string>;
|
|
53
|
+
failed: { name: string; reason: string }[];
|
|
54
|
+
};
|
|
55
|
+
restore_result: {
|
|
56
|
+
type: "restore_result";
|
|
57
|
+
id: string;
|
|
58
|
+
restored: string[];
|
|
59
|
+
failed: { name: string; reason: string }[];
|
|
60
|
+
};
|
|
61
|
+
names_result: { type: "names_result"; id: string; names: string[] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type GuestToHostMessage = GuestToHost[keyof GuestToHost];
|
|
65
|
+
|
|
66
|
+
export const ENVELOPE_KEY = "__rlm";
|
|
67
|
+
/** Env var carrying the per-process nonce to the guest. */
|
|
68
|
+
export const NONCE_ENV = "PI_RLM_NONCE";
|
|
69
|
+
/** Protocol pipe: guest → host. */
|
|
70
|
+
export const PROTOCOL_FD = 3;
|
|
71
|
+
|
|
72
|
+
export function encodeMessage(message: HostToGuestMessage | GuestToHostMessage, nonce?: string): string {
|
|
73
|
+
const envelope: Record<string, unknown> = { [ENVELOPE_KEY]: 1, ...message };
|
|
74
|
+
if (nonce) envelope.n = nonce;
|
|
75
|
+
return `${JSON.stringify(envelope)}\n`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function decodeMessage<T>(line: string, nonce?: string): T | null {
|
|
79
|
+
if (!line.includes(`"${ENVELOPE_KEY}":1`)) return null;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(line);
|
|
82
|
+
if (parsed?.[ENVELOPE_KEY] !== 1 || typeof parsed.type !== "string") return null;
|
|
83
|
+
if (nonce && parsed.n !== nonce) return null;
|
|
84
|
+
return parsed as T;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Dirent, lstatSync, readdirSync, type Stats } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Phase 2 crash-resume (D1/D1b/D12): locate the latest scratchpad snapshot
|
|
6
|
+
* artifact for a task inside the run's artifact store.
|
|
7
|
+
*
|
|
8
|
+
* Artifacts are written by the worker's flush path as
|
|
9
|
+
* `scratchpad/<taskId>.attempt-<i>.snapshot.json` (C3 per-attempt suffix, i =
|
|
10
|
+
* model-fallback index that resets to 0 on every retry round — so mtime, NOT
|
|
11
|
+
* the attempt number, is the write-order proxy).
|
|
12
|
+
*
|
|
13
|
+
* Selection (D1b): latest mtime wins; equal mtime ties break by LOWEST attempt
|
|
14
|
+
* number (ASC) — a new retry round restarts at i=0, so on a coarse-mtime tie
|
|
15
|
+
* the low-index file belongs to the NEWER round (MINOR-S6).
|
|
16
|
+
*
|
|
17
|
+
* Strictness (D12, cross-agent poisoning): only REGULAR files match; symlinks
|
|
18
|
+
* are rejected at readdir time via dirent (never followed); the name must be
|
|
19
|
+
* exactly `<agentId>.attempt-<digits>.snapshot.json`.
|
|
20
|
+
*
|
|
21
|
+
* Read-only, fail-open: any I/O error (missing dir, EACCES, file vanished in a
|
|
22
|
+
* race) yields null — the caller then simply skips the restore env.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface SnapshotHit {
|
|
26
|
+
path: string;
|
|
27
|
+
attempt: number;
|
|
28
|
+
mtimeMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SNAPSHOT_SUFFIX = ".snapshot.json";
|
|
32
|
+
|
|
33
|
+
export function findLatestScratchpadSnapshot(artifactsRoot: string, agentId: string): SnapshotHit | null {
|
|
34
|
+
const scratchpadDir = join(artifactsRoot, "scratchpad");
|
|
35
|
+
// SEC-7 (MINOR-CA-1): reject a symlinked scratchpad DIR itself before readdir
|
|
36
|
+
// (entry-level D12 checks cannot see a replaced directory) — fail-open null.
|
|
37
|
+
let dirStat: Stats;
|
|
38
|
+
try {
|
|
39
|
+
dirStat = lstatSync(scratchpadDir);
|
|
40
|
+
} catch {
|
|
41
|
+
return null; // missing dir — fail-open: no snapshot to resume from
|
|
42
|
+
}
|
|
43
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return null;
|
|
44
|
+
let entries: Dirent[];
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(scratchpadDir, { withFileTypes: true });
|
|
47
|
+
} catch {
|
|
48
|
+
// EACCES etc. — fail-open: no snapshot to resume from.
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const prefix = `${agentId}.attempt-`;
|
|
52
|
+
let best: SnapshotHit | null = null;
|
|
53
|
+
for (const dirent of entries) {
|
|
54
|
+
// D12: reject symlinks at readdir; only regular files match.
|
|
55
|
+
if (dirent.isSymbolicLink() || !dirent.isFile()) continue;
|
|
56
|
+
const name = dirent.name;
|
|
57
|
+
if (!name.startsWith(prefix) || !name.endsWith(SNAPSHOT_SUFFIX)) continue;
|
|
58
|
+
const attemptPart = name.slice(prefix.length, name.length - SNAPSHOT_SUFFIX.length);
|
|
59
|
+
if (!/^\d+$/.test(attemptPart)) continue;
|
|
60
|
+
const attempt = Number.parseInt(attemptPart, 10);
|
|
61
|
+
let stat: Stats;
|
|
62
|
+
try {
|
|
63
|
+
stat = lstatSync(join(scratchpadDir, name)); // lstat: double-check not a symlink (TOCTOU guard)
|
|
64
|
+
} catch {
|
|
65
|
+
continue; // file vanished between readdir and stat — skip
|
|
66
|
+
}
|
|
67
|
+
if (!stat.isFile()) continue;
|
|
68
|
+
const hit: SnapshotHit = { path: join(scratchpadDir, name), attempt, mtimeMs: stat.mtimeMs };
|
|
69
|
+
if (best === null || hit.mtimeMs > best.mtimeMs || (hit.mtimeMs === best.mtimeMs && hit.attempt < best.attempt)) {
|
|
70
|
+
best = hit;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return best;
|
|
74
|
+
}
|