@drej/agent 0.1.1 → 0.2.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/dist/index.mjs ADDED
@@ -0,0 +1,941 @@
1
+ import { Drej } from "drej";
2
+ import { SQLiteAdapter } from "@drej/sqlite";
3
+ import { existsSync } from "fs";
4
+ import { createHash } from "node:crypto";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ //#region src/config.ts
8
+ const CONFIG_FILE = "drej.config.json";
9
+ const DEFAULT_CONFIG = {
10
+ serverUrl: "http://localhost:8080",
11
+ apiKey: "",
12
+ adapterPath: "./.drej/ledger.db",
13
+ useServerProxy: true,
14
+ agentsDir: "./agents",
15
+ defaults: { resources: {
16
+ cpu: "1000m",
17
+ memory: "1Gi"
18
+ } }
19
+ };
20
+ /**
21
+ * Read `drej.config.json` from the current working directory and merge it with
22
+ * built-in defaults. Missing fields fall back to the defaults — the config file
23
+ * is fully optional.
24
+ */
25
+ async function readProjectConfig() {
26
+ if (!existsSync(CONFIG_FILE)) return DEFAULT_CONFIG;
27
+ const data = await Bun.file(CONFIG_FILE).json();
28
+ return {
29
+ ...DEFAULT_CONFIG,
30
+ ...data,
31
+ defaults: {
32
+ ...DEFAULT_CONFIG.defaults,
33
+ ...data.defaults ?? {},
34
+ resources: {
35
+ ...DEFAULT_CONFIG.defaults.resources,
36
+ ...data.defaults?.resources ?? {}
37
+ }
38
+ }
39
+ };
40
+ }
41
+ //#endregion
42
+ //#region src/schema.ts
43
+ function validateAgentSpec(data) {
44
+ if (!data || typeof data !== "object") throw new Error("Agent spec must be an object");
45
+ const item = data;
46
+ if (typeof item.name !== "string" || !item.name) throw new Error("Agent spec must have a 'name' string");
47
+ if (item.cli !== "pi") throw new Error(`Unsupported CLI: '${String(item.cli ?? "(missing)")}'. Supported values: pi`);
48
+ return item;
49
+ }
50
+ //#endregion
51
+ //#region src/adapters/pi.ts
52
+ const BRIDGE_SCRIPT = `
53
+ "use strict";
54
+ var spawn = require("child_process").spawn;
55
+ var createInterface = require("readline").createInterface;
56
+ var http = require("http");
57
+ var fs = require("fs");
58
+
59
+ var PORT = 3001;
60
+ var ENV_FILE = "/etc/drej-env";
61
+ var PI_CONFIG_FILE = "/etc/drej-pi.json";
62
+
63
+ // Re-read /etc/drej-env into process.env on each Pi (re)start so setEnv() changes take effect.
64
+ function loadEnv() {
65
+ if (!fs.existsSync(ENV_FILE)) return;
66
+ var lines = fs.readFileSync(ENV_FILE, "utf8").split("\\n");
67
+ for (var i = 0; i < lines.length; i++) {
68
+ var m = lines[i].match(/^export ([A-Za-z_][A-Za-z0-9_]*)="((?:[^"\\\\]|\\\\.)*)"$/);
69
+ if (m) process.env[m[1]] = m[2].replace(/\\\\"/g, '"').replace(/\\\\\\\\/g, "\\\\");
70
+ }
71
+ }
72
+
73
+ // Build the pi CLI args from /etc/drej-pi.json (model/provider config, written by the host).
74
+ // Supports: provider, model, resume (--continue to resume the most recent session).
75
+ function buildPiArgs() {
76
+ var args = ["--mode", "rpc", "--approve"];
77
+ try {
78
+ if (fs.existsSync(PI_CONFIG_FILE)) {
79
+ var cfg = JSON.parse(fs.readFileSync(PI_CONFIG_FILE, "utf8"));
80
+ if (cfg.provider) args.push("--provider", cfg.provider);
81
+ if (cfg.model) args.push("--model", cfg.model);
82
+ if (cfg.resume) args.push("--continue");
83
+ }
84
+ } catch (e) {}
85
+ return args;
86
+ }
87
+
88
+ // --- Ring-buffer logger ---
89
+ var logBuf = [];
90
+ function log(msg) {
91
+ var entry = "[" + new Date().toISOString() + "] " + msg;
92
+ if (logBuf.length >= 200) logBuf.shift();
93
+ logBuf.push(entry);
94
+ process.stderr.write(entry + "\\n");
95
+ }
96
+
97
+ // --- Pi process state ---
98
+ // All mutable state lives in one object so it's easy to see what changes on restart.
99
+ var state = {
100
+ proc: null, // current ChildProcess
101
+ rl: null, // readline interface on proc.stdout
102
+ ready: false, // true once Pi responds to the get_state probe
103
+ active: null, // { message, res, text, t0 } — the in-flight prompt, if any
104
+ queue: [], // pending items: { message, streamingBehavior?, res, text, t0 }
105
+ gen: 0, // incremented on every (re)start; guards against stale async callbacks
106
+ pendingCmds: {}, // id → { res, timer, bash? } — commands waiting for Pi's response ack
107
+ };
108
+
109
+ // Fail all in-flight pendingCmds cleanly, handling bash (SSE) vs regular (JSON) responses.
110
+ function cleanupPendingCmds(reason) {
111
+ Object.keys(state.pendingCmds).forEach(function(id) {
112
+ var p = state.pendingCmds[id];
113
+ clearTimeout(p.timer);
114
+ if (p.bash) {
115
+ try { p.res.write("data: " + JSON.stringify({ error: reason }) + "\\n\\n"); p.res.end(); } catch (e) {}
116
+ } else {
117
+ respond(p.res, 500, { ok: false, error: reason });
118
+ }
119
+ });
120
+ state.pendingCmds = {};
121
+ }
122
+
123
+ function startPi() {
124
+ // End any in-flight SSE stream so the client isn't left hanging.
125
+ if (state.active) {
126
+ state.active.res.write("data: " + JSON.stringify({ error: "pi restarted" }) + "\\n\\n");
127
+ state.active.res.end();
128
+ state.active = null;
129
+ }
130
+ cleanupPendingCmds("pi restarted");
131
+
132
+ if (state.rl) { try { state.rl.close(); } catch (e) {} }
133
+ if (state.proc) { try { state.proc.kill("SIGTERM"); } catch (e) {} }
134
+ state.proc = null;
135
+ state.rl = null;
136
+ state.ready = false;
137
+ // NB: state.queue is intentionally preserved — queued prompts are re-sent to the new Pi.
138
+
139
+ loadEnv();
140
+ var args = buildPiArgs();
141
+ var gen = ++state.gen;
142
+ log("pi start gen=" + gen + ": " + args.join(" "));
143
+
144
+ var proc = spawn("pi", args, {
145
+ stdio: ["pipe", "pipe", "pipe"],
146
+ env: Object.assign({}, process.env),
147
+ });
148
+ state.proc = proc;
149
+
150
+ proc.stderr.on("data", function(chunk) {
151
+ if (state.gen === gen) log("pi stderr: " + chunk.toString().trim());
152
+ });
153
+
154
+ state.rl = createInterface({ input: proc.stdout });
155
+ state.rl.on("line", function(line) {
156
+ if (state.gen === gen) handleLine(line);
157
+ });
158
+
159
+ proc.on("exit", function(code) {
160
+ if (state.gen !== gen) return;
161
+ log("pi exit gen=" + gen + " code=" + code);
162
+ state.proc = null;
163
+ state.ready = false;
164
+ cleanupPendingCmds("pi exited");
165
+ if (state.active) {
166
+ state.active.res.write("data: " + JSON.stringify({ error: "pi exited" }) + "\\n\\n");
167
+ state.active.res.end();
168
+ state.active = null;
169
+ }
170
+ });
171
+
172
+ // Pi needs ~500ms to initialise its RPC layer before it can handle commands.
173
+ setTimeout(function() {
174
+ if (state.gen !== gen || !state.proc) return;
175
+ log("pi probe gen=" + gen);
176
+ rpc({ id: "__probe__", type: "get_state" });
177
+ }, 500);
178
+ }
179
+
180
+ // Write a JSON-RPC message to Pi's stdin.
181
+ function rpc(msg) {
182
+ if (state.proc) state.proc.stdin.write(JSON.stringify(msg) + "\\n");
183
+ }
184
+
185
+ // Send an RPC command and hold the HTTP response open until Pi acks it.
186
+ function rpcWithAck(msg, res) {
187
+ state.pendingCmds[msg.id] = { res: res };
188
+ rpc(msg);
189
+ state.pendingCmds[msg.id].timer = setTimeout(function() {
190
+ if (state.pendingCmds[msg.id]) {
191
+ respond(state.pendingCmds[msg.id].res, 504, { error: "timeout" });
192
+ delete state.pendingCmds[msg.id];
193
+ }
194
+ }, 5000);
195
+ }
196
+
197
+ // Dispatch a single line of output from Pi's stdout.
198
+ function handleLine(line) {
199
+ if (!line.trim()) return;
200
+ var ev;
201
+ try { ev = JSON.parse(line); } catch (e) { return; }
202
+
203
+ if (!state.ready && ev.id === "__probe__" && ev.type === "response") {
204
+ state.ready = true;
205
+ var m = (ev.data && ev.data.model) || {};
206
+ log("pi ready model=" + m.id + " api=" + m.api);
207
+ flush();
208
+ return;
209
+ }
210
+
211
+ // Resolve a pending command ack.
212
+ // Bash returns output synchronously in ev.data — stream it as SSE then send [DONE].
213
+ // All other commands use JSON response.
214
+ if (ev.type === "response" && ev.id && state.pendingCmds[ev.id]) {
215
+ var pending = state.pendingCmds[ev.id];
216
+ clearTimeout(pending.timer);
217
+ delete state.pendingCmds[ev.id];
218
+ if (pending.bash) {
219
+ var output = (ev.data && ev.data.output) || "";
220
+ if (output) try { pending.res.write("data: " + JSON.stringify({ type: "text", text: output }) + "\\n\\n"); } catch (e) {}
221
+ try { pending.res.write("data: [DONE]\\n\\n"); pending.res.end(); } catch (e) {}
222
+ } else if (ev.success) {
223
+ respond(pending.res, 200, { ok: true, data: ev.data || null });
224
+ } else {
225
+ respond(pending.res, 400, { ok: false, error: ev.error || "unknown" });
226
+ }
227
+ return;
228
+ }
229
+
230
+ // Tool execution events: forward into the active prompt stream.
231
+ if (ev.type === "tool_execution_start") {
232
+ if (!state.active) return;
233
+ state.active.res.write("data: " + JSON.stringify({ type: "tool_start", toolCallId: ev.toolCallId, toolName: ev.toolName, args: ev.args }) + "\\n\\n");
234
+ return;
235
+ }
236
+ if (ev.type === "tool_execution_update") {
237
+ if (!state.active) return;
238
+ state.active.res.write("data: " + JSON.stringify({ type: "tool_update", toolCallId: ev.toolCallId, toolName: ev.toolName, partialResult: ev.partialResult }) + "\\n\\n");
239
+ return;
240
+ }
241
+ if (ev.type === "tool_execution_end") {
242
+ if (!state.active) return;
243
+ state.active.res.write("data: " + JSON.stringify({ type: "tool_end", toolCallId: ev.toolCallId, toolName: ev.toolName, result: ev.result, isError: !!ev.isError }) + "\\n\\n");
244
+ return;
245
+ }
246
+
247
+ var item = state.active;
248
+ if (!item) return;
249
+
250
+ // prompt: forward text_delta chunks and agent lifecycle
251
+ if (ev.type === "message_update") {
252
+ var aev = ev.assistantMessageEvent || {};
253
+ if (aev.type === "text_delta" && aev.delta) {
254
+ item.text += aev.delta;
255
+ item.res.write("data: " + JSON.stringify({ type: "text", text: aev.delta }) + "\\n\\n");
256
+ }
257
+ } else if (ev.type === "agent_end") {
258
+ log("agent_end: " + item.text.length + " chars in " + (Date.now() - item.t0) + "ms");
259
+ item.res.write("data: [DONE]\\n\\n");
260
+ item.res.end();
261
+ state.active = null;
262
+ flush();
263
+ }
264
+ }
265
+
266
+ // Send the next queued item to Pi, if Pi is ready and idle.
267
+ // streamingBehavior items ("steer"/"followUp") bypass the active-slot gate and complete immediately.
268
+ function flush() {
269
+ if (!state.ready || !state.queue.length || !state.proc) return;
270
+ var item = state.queue[0];
271
+ if (state.active && !item.streamingBehavior) return;
272
+ state.queue.shift();
273
+ if (!item.streamingBehavior) state.active = item;
274
+ item.t0 = Date.now();
275
+ var rpcMsg = { id: "p" + item.t0, type: "prompt", message: item.message };
276
+ if (item.streamingBehavior) rpcMsg.streamingBehavior = item.streamingBehavior;
277
+ rpc(rpcMsg);
278
+ item.res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
279
+ // Injections don't produce their own SSE body — their output arrives via the active prompt's stream.
280
+ if (item.streamingBehavior) {
281
+ item.res.write("data: [DONE]\\n\\n");
282
+ item.res.end();
283
+ flush();
284
+ }
285
+ }
286
+
287
+ // HTTP response helpers.
288
+ function respond(res, status, body) {
289
+ try {
290
+ res.writeHead(status, { "Content-Type": "application/json" });
291
+ res.end(JSON.stringify(body));
292
+ } catch (e) {}
293
+ }
294
+
295
+ // --- HTTP server ---
296
+ startPi();
297
+
298
+ http.createServer(function(req, res) {
299
+ // GET endpoints don't need a body.
300
+ if (req.method === "GET") {
301
+ if (req.url === "/health") { respond(res, 200, { ok: state.ready }); return; }
302
+ if (req.url === "/logs") { res.writeHead(200, { "Content-Type": "text/plain" }); res.end(logBuf.join("\\n")); return; }
303
+ if (req.url === "/messages") { rpcWithAck({ id: "gm" + Date.now(), type: "get_messages" }, res); return; }
304
+ if (req.url === "/available-models") { rpcWithAck({ id: "gam" + Date.now(), type: "get_available_models" }, res); return; }
305
+ res.writeHead(404); res.end();
306
+ return;
307
+ }
308
+
309
+ if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
310
+
311
+ var body = "";
312
+ req.on("data", function(d) { body += d; });
313
+ req.on("end", function() {
314
+ var data = {};
315
+ try { if (body) data = JSON.parse(body); } catch (e) { res.writeHead(400); res.end("bad json"); return; }
316
+
317
+ switch (req.url) {
318
+ case "/prompt":
319
+ state.queue.push({ message: data.message || "", streamingBehavior: data.streamingBehavior, res: res, text: "", t0: 0 });
320
+ flush();
321
+ return;
322
+
323
+ case "/bash": {
324
+ // Pi returns bash output synchronously in the ack's data.output — no streaming events.
325
+ if (!state.ready || !state.proc) { respond(res, 503, { error: "pi not ready" }); return; }
326
+ var bashId = "b" + Date.now();
327
+ var bashTimer = setTimeout(function() {
328
+ if (state.pendingCmds[bashId]) {
329
+ delete state.pendingCmds[bashId];
330
+ try { res.write("data: " + JSON.stringify({ error: "bash timeout" }) + "\\n\\n"); res.end(); } catch (e) {}
331
+ }
332
+ }, 30000);
333
+ state.pendingCmds[bashId] = { res: res, timer: bashTimer, bash: true };
334
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
335
+ rpc({ id: bashId, type: "bash", command: data.command || "" });
336
+ return;
337
+ }
338
+
339
+ case "/abort":
340
+ // End the active SSE stream immediately, drain the queue, then wait for Pi's ack.
341
+ state.queue.length = 0;
342
+ if (state.active) {
343
+ state.active.res.write("data: [DONE]\\n\\n");
344
+ state.active.res.end();
345
+ state.active = null;
346
+ }
347
+ rpcWithAck({ id: "a" + Date.now(), type: "abort" }, res);
348
+ return;
349
+
350
+ case "/steer":
351
+ rpcWithAck({ id: "s" + Date.now(), type: "steer", message: data.message || "" }, res);
352
+ return;
353
+
354
+ case "/follow-up":
355
+ rpcWithAck({ id: "fu" + Date.now(), type: "follow_up", message: data.message || "" }, res);
356
+ return;
357
+
358
+ case "/new-session":
359
+ rpcWithAck({ id: "n" + Date.now(), type: "new_session" }, res);
360
+ return;
361
+
362
+ case "/fork":
363
+ rpcWithAck({ id: "fk" + Date.now(), type: "fork", entryId: data.entryId || "" }, res);
364
+ return;
365
+
366
+ case "/clone":
367
+ rpcWithAck({ id: "cl" + Date.now(), type: "clone" }, res);
368
+ return;
369
+
370
+ case "/switch-session":
371
+ rpcWithAck({ id: "ss" + Date.now(), type: "switch_session", sessionPath: data.sessionPath || "" }, res);
372
+ return;
373
+
374
+ case "/set-model":
375
+ rpcWithAck({ id: "sm" + Date.now(), type: "set_model", provider: data.provider || "", modelId: data.modelId || "" }, res);
376
+ return;
377
+
378
+ case "/cycle-model":
379
+ rpcWithAck({ id: "cm" + Date.now(), type: "cycle_model" }, res);
380
+ return;
381
+
382
+ case "/set-thinking-level":
383
+ rpcWithAck({ id: "stl" + Date.now(), type: "set_thinking_level", level: data.level || "medium" }, res);
384
+ return;
385
+
386
+ case "/cycle-thinking-level":
387
+ rpcWithAck({ id: "ctl" + Date.now(), type: "cycle_thinking_level" }, res);
388
+ return;
389
+
390
+ case "/compact": {
391
+ var compactMsg = { id: "co" + Date.now(), type: "compact" };
392
+ if (data.customInstructions) compactMsg.customInstructions = data.customInstructions;
393
+ rpcWithAck(compactMsg, res);
394
+ return;
395
+ }
396
+
397
+ case "/set-auto-compaction":
398
+ rpcWithAck({ id: "sac" + Date.now(), type: "set_auto_compaction", enabled: !!data.enabled }, res);
399
+ return;
400
+
401
+ case "/reload-env":
402
+ // Merge any inline env the host sent, then restart Pi so it picks up the new env file.
403
+ if (data.env && typeof data.env === "object") Object.assign(process.env, data.env);
404
+ startPi();
405
+ respond(res, 200, { ok: true });
406
+ return;
407
+
408
+ default:
409
+ res.writeHead(404); res.end("not found");
410
+ }
411
+ });
412
+ }).listen(PORT, "0.0.0.0", function() {
413
+ process.stderr.write("drej-bridge :" + PORT + "\\n");
414
+ });
415
+ `;
416
+ function toShellExports(env) {
417
+ return Object.entries(env).map(([k, v]) => `export ${k}="${v.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`).join("\n") + "\n";
418
+ }
419
+ function resolveEnv(env) {
420
+ const result = {};
421
+ for (const [key, value] of Object.entries(env)) result[key] = value.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
422
+ return result;
423
+ }
424
+ var PiAdapter = class {
425
+ _bridgeUrl = null;
426
+ get bridgeUrl() {
427
+ if (!this._bridgeUrl) throw new Error("PiAdapter: bridge not started");
428
+ return this._bridgeUrl;
429
+ }
430
+ /** Install Pi CLI and any spec packages. Slow — result is captured by checkpoint(). */
431
+ async install(sb, spec) {
432
+ const pkgs = [...new Set(spec.packages ?? [])].filter((p) => p !== "nodejs_22" && p !== "nodejs");
433
+ if (pkgs.length > 0) await sb.exec(`apt-get update -qq && apt-get install -y --no-install-recommends ${pkgs.join(" ")}`);
434
+ await sb.exec("npm install -g --ignore-scripts @earendil-works/pi-coding-agent");
435
+ }
436
+ /**
437
+ * Write config files and the bridge script. Always runs on every start (fresh install
438
+ * and snapshot resume alike) so env values, model/provider, and bridge code stay current.
439
+ */
440
+ async configure(sb, spec, resolvedEnv, opts) {
441
+ const piConfig = {};
442
+ if (spec.provider) piConfig.provider = spec.provider;
443
+ if (spec.model) piConfig.model = spec.model;
444
+ if (opts?.resume) piConfig.resume = true;
445
+ await sb.writeFile("/etc/drej-pi.json", JSON.stringify(piConfig));
446
+ await sb.writeFile("/etc/drej-env", toShellExports(resolvedEnv));
447
+ await sb.writeFile("/drej-bridge.js", BRIDGE_SCRIPT);
448
+ }
449
+ async startBridge(sb) {
450
+ await sb.exec("node /drej-bridge.js &");
451
+ const { url } = await sb.proxy(3001);
452
+ this._bridgeUrl = url;
453
+ }
454
+ async waitReady(timeoutMs = 3e4) {
455
+ const deadline = Date.now() + timeoutMs;
456
+ while (Date.now() < deadline) {
457
+ try {
458
+ const res = await fetch(`${this.bridgeUrl}/health`);
459
+ if (res.ok) {
460
+ if ((await res.json()).ok) return;
461
+ }
462
+ } catch {}
463
+ await new Promise((r) => setTimeout(r, 500));
464
+ }
465
+ throw new Error(`drej-bridge did not become ready within ${timeoutMs / 1e3}s`);
466
+ }
467
+ prompt(message, opts) {
468
+ return sseStream(this.bridgeUrl, "/prompt", {
469
+ message,
470
+ streamingBehavior: opts?.streamingBehavior
471
+ });
472
+ }
473
+ bash(command) {
474
+ return sseStream(this.bridgeUrl, "/bash", { command });
475
+ }
476
+ async steer(message) {
477
+ const res = await fetch(`${this.bridgeUrl}/steer`, {
478
+ method: "POST",
479
+ headers: { "Content-Type": "application/json" },
480
+ body: JSON.stringify({ message })
481
+ });
482
+ if (!res.ok) {
483
+ const body = await res.json().catch(() => ({}));
484
+ throw new Error(`steer failed: ${body.error ?? res.status}`);
485
+ }
486
+ }
487
+ async abort() {
488
+ await rpcPost(this.bridgeUrl, "/abort");
489
+ }
490
+ async followUp(message) {
491
+ await rpcPost(this.bridgeUrl, "/follow-up", { message });
492
+ }
493
+ async newSession() {
494
+ await rpcPost(this.bridgeUrl, "/new-session");
495
+ }
496
+ async setThinkingLevel(level) {
497
+ await rpcPost(this.bridgeUrl, "/set-thinking-level", { level });
498
+ }
499
+ async setAutoCompaction(enabled) {
500
+ await rpcPost(this.bridgeUrl, "/set-auto-compaction", { enabled });
501
+ }
502
+ async fork(entryId) {
503
+ return rpcPost(this.bridgeUrl, "/fork", { entryId });
504
+ }
505
+ async clone() {
506
+ return rpcPost(this.bridgeUrl, "/clone");
507
+ }
508
+ async switchSession(sessionPath) {
509
+ return rpcPost(this.bridgeUrl, "/switch-session", { sessionPath });
510
+ }
511
+ async setModel(provider, modelId) {
512
+ return rpcPost(this.bridgeUrl, "/set-model", {
513
+ provider,
514
+ modelId
515
+ });
516
+ }
517
+ async cycleModel() {
518
+ return rpcPost(this.bridgeUrl, "/cycle-model");
519
+ }
520
+ async cycleThinkingLevel() {
521
+ return rpcPost(this.bridgeUrl, "/cycle-thinking-level");
522
+ }
523
+ async compact(customInstructions) {
524
+ return rpcPost(this.bridgeUrl, "/compact", { customInstructions });
525
+ }
526
+ async getMessages() {
527
+ return (await rpcGet(this.bridgeUrl, "/messages")).messages;
528
+ }
529
+ async getAvailableModels() {
530
+ return (await rpcGet(this.bridgeUrl, "/available-models")).models;
531
+ }
532
+ async reloadEnv(env) {
533
+ await rpcPost(this.bridgeUrl, "/reload-env", { env });
534
+ await this.waitReady();
535
+ }
536
+ async getLogs() {
537
+ return (await fetch(`${this.bridgeUrl}/logs`)).text();
538
+ }
539
+ };
540
+ async function rpcPost(bridgeUrl, path, body = {}) {
541
+ const res = await fetch(`${bridgeUrl}${path}`, {
542
+ method: "POST",
543
+ headers: { "Content-Type": "application/json" },
544
+ body: JSON.stringify(body)
545
+ });
546
+ if (!res.ok) {
547
+ const err = await res.json().catch(() => ({}));
548
+ throw new Error(`${path} failed: ${err.error ?? res.status}`);
549
+ }
550
+ return (await res.json()).data;
551
+ }
552
+ async function rpcGet(bridgeUrl, path) {
553
+ const res = await fetch(`${bridgeUrl}${path}`);
554
+ if (!res.ok) throw new Error(`${path} failed: ${res.status}`);
555
+ return (await res.json()).data;
556
+ }
557
+ async function* sseStream(bridgeUrl, path, body) {
558
+ const res = await fetch(`${bridgeUrl}${path}`, {
559
+ method: "POST",
560
+ headers: { "Content-Type": "application/json" },
561
+ body: JSON.stringify(body)
562
+ });
563
+ if (!res.ok || !res.body) throw new Error(`Bridge ${path} error: ${res.status}`);
564
+ const reader = res.body.getReader();
565
+ const decoder = new TextDecoder();
566
+ let buf = "";
567
+ while (true) {
568
+ const { done, value } = await reader.read();
569
+ if (done) break;
570
+ buf += decoder.decode(value, { stream: true });
571
+ const lines = buf.split("\n");
572
+ buf = lines.pop();
573
+ for (const line of lines) {
574
+ if (!line.startsWith("data: ")) continue;
575
+ const payload = line.slice(6).trim();
576
+ if (payload === "[DONE]") return;
577
+ const raw = JSON.parse(payload);
578
+ if (raw.error) throw new Error(`Bridge error: ${raw.error}`);
579
+ yield raw;
580
+ }
581
+ }
582
+ }
583
+ //#endregion
584
+ //#region src/snapshots.ts
585
+ /**
586
+ * Hash of the fields that require re-installing the agent CLI:
587
+ * cli, cliVersion, and packages. Excludes env (hot-reloadable),
588
+ * model/provider (CLI flags only), and cosmetic fields.
589
+ */
590
+ function computeSetupHash(spec) {
591
+ return createHash("sha256").update(JSON.stringify({
592
+ cli: spec.cli,
593
+ cliVersion: spec.cliVersion ?? "latest",
594
+ packages: [...spec.packages ?? []].sort(),
595
+ setup: spec.setup ?? []
596
+ })).digest("hex").slice(0, 12);
597
+ }
598
+ /** Derive the agent-snapshots.json path from the ledger adapter path. */
599
+ function snapshotsPath(adapterPath) {
600
+ return `${dirname(adapterPath)}/agent-snapshots.json`;
601
+ }
602
+ /** File-backed store for agent snapshot records, keyed by specName + setupHash. */
603
+ var AgentSnapshotStore = class {
604
+ path;
605
+ constructor(path) {
606
+ this.path = path;
607
+ }
608
+ async _read() {
609
+ try {
610
+ return JSON.parse(await readFile(this.path, "utf-8"));
611
+ } catch {
612
+ return {};
613
+ }
614
+ }
615
+ async _write(data) {
616
+ await mkdir(dirname(this.path), { recursive: true });
617
+ await writeFile(this.path, JSON.stringify(data, null, 2));
618
+ }
619
+ _key(specName, setupHash) {
620
+ return `${specName}:${setupHash}`;
621
+ }
622
+ async get(specName, setupHash) {
623
+ return (await this._read())[this._key(specName, setupHash)] ?? null;
624
+ }
625
+ async save(record) {
626
+ const data = await this._read();
627
+ for (const k of Object.keys(data)) if (data[k].specName === record.specName) delete data[k];
628
+ data[this._key(record.specName, record.setupHash)] = record;
629
+ await this._write(data);
630
+ }
631
+ /** Remove all snapshot records for the given spec name. */
632
+ async delete(specName) {
633
+ const data = await this._read();
634
+ for (const k of Object.keys(data)) if (data[k].specName === specName) delete data[k];
635
+ await this._write(data);
636
+ }
637
+ async list() {
638
+ return Object.values(await this._read());
639
+ }
640
+ };
641
+ //#endregion
642
+ //#region src/agent.ts
643
+ function elapsed(t) {
644
+ return `${Date.now() - t}ms`;
645
+ }
646
+ /**
647
+ * A live AI coding agent running inside an OpenSandbox container.
648
+ *
649
+ * Wraps a Pi CLI process (`pi --mode rpc --approve`) in an HTTP bridge so the
650
+ * host can send prompts and receive streamed responses over a stable API,
651
+ * while Pi manages its own tool use, file writes, and code execution inside the
652
+ * sandbox.
653
+ *
654
+ * Create an agent with `Agent.load(specPath)`. Always call `close()` when done
655
+ * to release the underlying sandbox container.
656
+ *
657
+ * @example
658
+ * ```ts
659
+ * import { Agent } from "@drej/agent";
660
+ *
661
+ * const agent = await Agent.load("./agents/my-agent.json");
662
+ * try {
663
+ * for await (const chunk of agent.prompt("Explain this codebase")) {
664
+ * process.stdout.write(chunk);
665
+ * }
666
+ * } finally {
667
+ * await agent.close();
668
+ * }
669
+ * ```
670
+ */
671
+ var Agent = class Agent {
672
+ /** OpenSandbox container ID for this agent's sandbox. */
673
+ sandboxId;
674
+ name;
675
+ /**
676
+ * Direct access to the underlying `Sandbox` — full drej Sandbox API, bypasses Pi.
677
+ * Use this to read or write files, run shell commands, or inspect container state
678
+ * independently of the Pi conversation.
679
+ */
680
+ sandbox;
681
+ /**
682
+ * `true` when this agent was loaded from a cached snapshot (fast path).
683
+ * `false` on the first load for a given spec, or after `{ rebuild: true }`.
684
+ */
685
+ fromSnapshot;
686
+ _adapter;
687
+ _env;
688
+ constructor(sandbox, spec, env, adapter, fromSnapshot) {
689
+ this.sandbox = sandbox;
690
+ this.sandboxId = sandbox.sandboxId;
691
+ this.name = spec.name;
692
+ this._adapter = adapter;
693
+ this._env = env;
694
+ this.fromSnapshot = fromSnapshot;
695
+ }
696
+ /**
697
+ * Load an agent spec from disk and return a fully initialised `Agent`.
698
+ *
699
+ * On first load the Pi CLI is installed inside a `node:22` sandbox, then
700
+ * the sandbox is checkpointed. Subsequent `load()` calls for the same spec
701
+ * restore from that snapshot — skipping the install and starting in ~3s instead
702
+ * of ~90s.
703
+ *
704
+ * Pass `{ rebuild: true }` to force a full reinstall (e.g. after changing
705
+ * the spec's `packages` or `cliVersion`).
706
+ *
707
+ * Logs timing for each phase to stdout via `[agent]` prefixed lines.
708
+ */
709
+ static async load(specPath, opts) {
710
+ const t0 = Date.now();
711
+ const spec = validateAgentSpec(await Bun.file(specPath).json());
712
+ const config = await readProjectConfig();
713
+ const resolvedEnv = resolveEnv(spec.env ?? {});
714
+ const resources = {
715
+ ...config.defaults.resources,
716
+ ...spec.resources ?? {}
717
+ };
718
+ const client = new Drej({
719
+ baseUrl: config.serverUrl,
720
+ apiKey: config.apiKey,
721
+ adapter: new SQLiteAdapter(config.adapterPath),
722
+ useServerProxy: config.useServerProxy
723
+ });
724
+ const store = new AgentSnapshotStore(snapshotsPath(config.adapterPath));
725
+ const setupHash = computeSetupHash(spec);
726
+ const adapter = new PiAdapter();
727
+ let sb;
728
+ let fromSnapshot = false;
729
+ if (!opts?.rebuild) {
730
+ const record = await store.get(spec.name, setupHash);
731
+ if (record) try {
732
+ console.log(`[agent] restoring from snapshot...`);
733
+ const t1 = Date.now();
734
+ sb = await client.restoreSnapshot(record.snapshotId, spec.name, resources);
735
+ console.log(`[agent] snapshot ready ${elapsed(t1)} (${sb.sandboxId})`);
736
+ fromSnapshot = true;
737
+ } catch {
738
+ console.log(`[agent] snapshot stale, rebuilding...`);
739
+ await store.delete(spec.name);
740
+ }
741
+ }
742
+ if (!fromSnapshot) {
743
+ console.log(`[agent] starting sandbox (${spec.name})...`);
744
+ const t1 = Date.now();
745
+ sb = await client.sandbox({
746
+ image: "node:22",
747
+ resources,
748
+ name: spec.name,
749
+ env: resolvedEnv
750
+ });
751
+ console.log(`[agent] sandbox ready ${elapsed(t1)} (${sb.sandboxId})`);
752
+ console.log(`[agent] installing Pi CLI...`);
753
+ const t2 = Date.now();
754
+ await adapter.install(sb, spec);
755
+ console.log(`[agent] Pi CLI ready ${elapsed(t2)}`);
756
+ for (const step of spec.setup ?? []) {
757
+ console.log(`[agent] setup: ${step.name}...`);
758
+ const ts = Date.now();
759
+ const cmd = step.cwd ? `cd ${step.cwd} && ${step.run}` : step.run;
760
+ await sb.exec(cmd);
761
+ console.log(`[agent] setup done ${elapsed(ts)} (${step.name})`);
762
+ }
763
+ console.log(`[agent] checkpointing...`);
764
+ const t3 = Date.now();
765
+ const snapshotId = await sb.checkpoint();
766
+ await store.save({
767
+ specName: spec.name,
768
+ setupHash,
769
+ snapshotId,
770
+ createdAt: Date.now()
771
+ });
772
+ console.log(`[agent] checkpoint done ${elapsed(t3)}`);
773
+ }
774
+ await adapter.configure(sb, spec, resolvedEnv);
775
+ console.log(`[agent] starting bridge...`);
776
+ const t4 = Date.now();
777
+ await adapter.startBridge(sb);
778
+ await adapter.waitReady();
779
+ console.log(`[agent] bridge ready ${elapsed(t4)}`);
780
+ console.log(`[agent] total ${elapsed(t0)}${fromSnapshot ? " (from snapshot)" : ""}`);
781
+ return new Agent(sb, spec, resolvedEnv, adapter, fromSnapshot);
782
+ }
783
+ /**
784
+ * Reconnect to a previously-created agent whose host process has exited.
785
+ *
786
+ * The sandbox container must still be running. Pi and any installed packages
787
+ * are already present — only the bridge process needs to be restarted.
788
+ * Pi is started with `--continue` so it resumes the most recent session.
789
+ *
790
+ * @param sandboxId The sandbox ID returned by the original `Agent.load()`.
791
+ * @param opts.specPath Path to the agent spec JSON. If omitted, the ledger
792
+ * is queried for the sandbox name and the spec is loaded from
793
+ * `./agents/<name>.json`.
794
+ *
795
+ * @example
796
+ * ```ts
797
+ * // Original process:
798
+ * const agent = await Agent.load("./agents/hello-agent.json");
799
+ * console.log(agent.sandboxId); // save this
800
+ * // ... process exits ...
801
+ *
802
+ * // New process:
803
+ * const agent = await Agent.resume(savedSandboxId);
804
+ * for await (const chunk of agent.prompt("What did we discuss earlier?")) {
805
+ * process.stdout.write(chunk);
806
+ * }
807
+ * await agent.close();
808
+ * ```
809
+ */
810
+ static async resume(sandboxId, opts) {
811
+ const t0 = Date.now();
812
+ const config = await readProjectConfig();
813
+ const client = new Drej({
814
+ baseUrl: config.serverUrl,
815
+ apiKey: config.apiKey,
816
+ adapter: new SQLiteAdapter(config.adapterPath),
817
+ useServerProxy: config.useServerProxy
818
+ });
819
+ let spec;
820
+ if (opts?.specPath) spec = validateAgentSpec(await Bun.file(opts.specPath).json());
821
+ else {
822
+ const session = (await client.sandboxes.list()).find((s) => s.sandboxId === sandboxId);
823
+ if (!session) throw new Error(`No ledger record for sandbox ${sandboxId} — pass opts.specPath explicitly`);
824
+ spec = validateAgentSpec(await Bun.file(`./agents/${session.name}.json`).json());
825
+ }
826
+ const resolvedEnv = resolveEnv(spec.env ?? {});
827
+ console.log(`[agent] reconnecting to ${sandboxId}...`);
828
+ const t1 = Date.now();
829
+ const sb = await client.connect(sandboxId, spec.name);
830
+ console.log(`[agent] connected ${elapsed(t1)}`);
831
+ await sb.exec("pkill -f 'node /drej-bridge.js' 2>/dev/null; sleep 0.1; true", { strict: false });
832
+ const adapter = new PiAdapter();
833
+ await adapter.configure(sb, spec, resolvedEnv, { resume: true });
834
+ console.log(`[agent] starting bridge...`);
835
+ const t2 = Date.now();
836
+ await adapter.startBridge(sb);
837
+ await adapter.waitReady();
838
+ console.log(`[agent] bridge ready ${elapsed(t2)}`);
839
+ console.log(`[agent] total ${elapsed(t0)}`);
840
+ return new Agent(sb, spec, resolvedEnv, adapter, false);
841
+ }
842
+ /** Send a prompt to Pi and stream the response. Pi manages its own session context. */
843
+ prompt(message, opts) {
844
+ return this._adapter.prompt(message, opts);
845
+ }
846
+ /** Run a shell command inside Pi's working context and stream stdout. */
847
+ bash(command) {
848
+ return this._adapter.bash(command);
849
+ }
850
+ /** Steer Pi's current response mid-flight. Waits for Pi's RPC acknowledgment. */
851
+ async steer(message) {
852
+ return this._adapter.steer(message);
853
+ }
854
+ /** Abort Pi's current operation. */
855
+ async abort() {
856
+ return this._adapter.abort();
857
+ }
858
+ /** Queue a message to be sent to Pi after it finishes its current task. */
859
+ async followUp(message) {
860
+ return this._adapter.followUp(message);
861
+ }
862
+ /** Start a fresh Pi session, clearing all prior context. */
863
+ async newSession() {
864
+ return this._adapter.newSession();
865
+ }
866
+ /** Set Pi's reasoning level (for models that support extended thinking). */
867
+ async setThinkingLevel(level) {
868
+ return this._adapter.setThinkingLevel(level);
869
+ }
870
+ /** Enable or disable Pi's automatic context compaction. */
871
+ async setAutoCompaction(enabled) {
872
+ return this._adapter.setAutoCompaction(enabled);
873
+ }
874
+ /**
875
+ * Fork Pi's session at the given entry ID, creating a new branch.
876
+ * Returns the text of the forked message and whether the fork was cancelled.
877
+ */
878
+ async fork(entryId) {
879
+ return this._adapter.fork(entryId);
880
+ }
881
+ /** Clone the current Pi session into a new branch at the current position. */
882
+ async clone() {
883
+ return this._adapter.clone();
884
+ }
885
+ /** Switch Pi to a different session file on disk. */
886
+ async switchSession(sessionPath) {
887
+ return this._adapter.switchSession(sessionPath);
888
+ }
889
+ /** Switch Pi to a specific model. Returns the activated model. */
890
+ async setModel(provider, modelId) {
891
+ return this._adapter.setModel(provider, modelId);
892
+ }
893
+ /** Cycle Pi to the next available model. Returns null if only one model is configured. */
894
+ async cycleModel() {
895
+ return this._adapter.cycleModel();
896
+ }
897
+ /** Cycle Pi's thinking level. Returns null if the current model doesn't support thinking. */
898
+ async cycleThinkingLevel() {
899
+ return this._adapter.cycleThinkingLevel();
900
+ }
901
+ /** Manually trigger Pi's context compaction. */
902
+ async compact(customInstructions) {
903
+ return this._adapter.compact(customInstructions);
904
+ }
905
+ /** Retrieve Pi's full conversation history for the current session. */
906
+ async getMessages() {
907
+ return this._adapter.getMessages();
908
+ }
909
+ /** List all models available to Pi under the current provider configuration. */
910
+ async getAvailableModels() {
911
+ return this._adapter.getAvailableModels();
912
+ }
913
+ /**
914
+ * Set or update env vars in the running container. Writes to /etc/drej-env and restarts
915
+ * the Pi subprocess so it picks up the new env. Waits until Pi is ready before returning.
916
+ */
917
+ async setEnv(vars) {
918
+ this._env = {
919
+ ...this._env,
920
+ ...vars
921
+ };
922
+ await this.sandbox.writeFile("/etc/drej-env", toShellExports(this._env));
923
+ await this._adapter.reloadEnv(this._env);
924
+ }
925
+ /** Retrieve recent bridge logs (ring-buffered, last 200 entries). */
926
+ async getLogs() {
927
+ return this._adapter.getLogs();
928
+ }
929
+ /** Delete the sandbox container and release all resources. Always call in a `finally` block. */
930
+ async close() {
931
+ await this.sandbox.close();
932
+ }
933
+ };
934
+ //#endregion
935
+ //#region src/types.ts
936
+ /** Filter an `AgentStream` down to just text chunks. */
937
+ async function* textOnly(stream) {
938
+ for await (const ev of stream) if (ev.type === "text") yield ev.text;
939
+ }
940
+ //#endregion
941
+ export { Agent, AgentSnapshotStore, computeSetupHash, snapshotsPath, textOnly, validateAgentSpec };