codebay 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.mochi/manifest.json +778 -646
  2. package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
  7. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
  8. package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
  9. package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
  10. package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
  11. package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
  12. package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
  13. package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
  14. package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
  15. package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
  16. package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
  17. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
  18. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
  21. package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
  22. package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
  23. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
  24. package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
  25. package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
  26. package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
  27. package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
  28. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
  29. package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
  30. package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
  31. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
  32. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  33. package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
  34. package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
  35. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  36. package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
  37. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  38. package/README.md +43 -3
  39. package/migrations/0009_agent_runs.sql +24 -0
  40. package/migrations/0010_agent_run_model.sql +2 -0
  41. package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
  42. package/package.json +13 -9
  43. package/src/__fixtures__/IdeBarStrip.svelte +41 -0
  44. package/src/avatars/dog.ts +1 -1
  45. package/src/avatars/lightning.ts +8 -9
  46. package/src/avatars/rocket.ts +4 -5
  47. package/src/components/AgentLogBox.svelte +458 -0
  48. package/src/components/AppShell.svelte +112 -1
  49. package/src/components/DropZone.svelte +77 -0
  50. package/src/components/FolderBrowser.svelte +13 -5
  51. package/src/components/HealthBox.svelte +6 -5
  52. package/src/components/IdeBar.svelte +222 -76
  53. package/src/components/InstanceCard.svelte +38 -0
  54. package/src/components/InstanceView.svelte +83 -2
  55. package/src/components/SettingsView.svelte +273 -0
  56. package/src/components/TerminalPane.svelte +54 -0
  57. package/src/components/UiShowcase.svelte +23 -3
  58. package/src/container-injections/claude-code-install.ts +1 -1
  59. package/src/container-injections/claude-history.ts +52 -0
  60. package/src/container-injections/claude-statusline.ts +4 -1
  61. package/src/container-injections/github-credentials.ts +12 -1
  62. package/src/container-injections/tmux.ts +6 -2
  63. package/src/index.ts +35 -7
  64. package/src/lib/agent-run-stream.ts +286 -0
  65. package/src/lib/agent-runs.server.ts +735 -0
  66. package/src/lib/auth.server.ts +11 -2
  67. package/src/lib/build-freshness.server.ts +71 -0
  68. package/src/lib/config.server.ts +36 -7
  69. package/src/lib/container-files.server.ts +1 -1
  70. package/src/lib/data-dir-lock.server.ts +78 -0
  71. package/src/lib/db.server.ts +124 -1
  72. package/src/lib/devcontainer.server.ts +93 -16
  73. package/src/lib/docker-client.server.ts +11 -5
  74. package/src/lib/drives.ts +11 -0
  75. package/src/lib/exec.server.ts +66 -5
  76. package/src/lib/injections.server.ts +5 -1
  77. package/src/lib/instances.server.ts +66 -7
  78. package/src/lib/log-capture.server.ts +63 -23
  79. package/src/lib/mcp-auth.server.ts +52 -0
  80. package/src/lib/osc52.ts +18 -0
  81. package/src/lib/picker.server.ts +23 -2
  82. package/src/lib/sandbox-ops.server.ts +263 -0
  83. package/src/lib/upload.ts +32 -0
  84. package/src/lib/uploads.server.ts +164 -0
  85. package/src/mcp/routes.server.ts +25 -0
  86. package/src/mcp/server.server.ts +55 -0
  87. package/src/mcp/tools.server.ts +660 -0
  88. package/src/pages/Instance.svelte +6 -2
  89. package/src/pages/Settings.svelte +15 -0
  90. package/src/routes.ts +79 -3
  91. package/src/types.ts +17 -0
  92. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  93. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  94. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  95. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  96. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  97. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  98. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  99. package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
  100. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  101. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  102. package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
  103. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  104. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
  105. package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
  106. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
  107. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
  108. package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
@@ -0,0 +1,735 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { LOGS_DIR } from './config.server.ts';
4
+ import {
5
+ getInstance,
6
+ getRun,
7
+ insertRun,
8
+ openRunFor,
9
+ openRuns,
10
+ updateRun,
11
+ type AgentRunRow,
12
+ type InstanceRow
13
+ } from './db.server.ts';
14
+ import {
15
+ EXEC_STDIN_MAX_BYTES,
16
+ execInContainer,
17
+ execTargetFor as targetFor,
18
+ markerLine
19
+ } from './exec.server.ts';
20
+ import {
21
+ HOME_PRELUDE,
22
+ shellSingleQuote as quote,
23
+ writeContainerFile
24
+ } from './container-files.server.ts';
25
+ import {
26
+ FETCH_MARKER,
27
+ parseFetchBlocks,
28
+ sizeOnDisk,
29
+ tailBlockScript
30
+ } from './log-capture.server.ts';
31
+ import {
32
+ AGENT_RUN_MARKER,
33
+ CLAUDE_BINARY_WAIT_SECONDS,
34
+ SOURCE_INJECTED_ENV,
35
+ WAIT_FOR_INJECTIONS
36
+ } from './devcontainer.server.ts';
37
+ import {
38
+ claudePermissionFlags,
39
+ type AgentRunSummary,
40
+ type ClaudePermissionMode
41
+ } from '../types.ts';
42
+ import {
43
+ STRUCTURED_OUTPUT_FAILED_ERROR,
44
+ emptyRunState,
45
+ parseRunTimeline,
46
+ readRunChunk,
47
+ readRunFile,
48
+ type RunStreamState,
49
+ type RunTimelineEntry
50
+ } from './agent-run-stream.ts';
51
+
52
+ /** Fast enough that a caller polling `get_run` sees movement, cheap enough to leave always-on. */
53
+ const POLL_MS = 3000;
54
+
55
+ const STATE_MARKER = '__CODEBAY_RUNSTATE__';
56
+ const ERR_MARKER = '__CODEBAY_RUNERR__';
57
+
58
+ /** A run whose launcher process vanished without an exit file is dead; don't wait forever. */
59
+ const MISSING_PROCESS_STRIKES = 5;
60
+
61
+ /** A run nobody stops would otherwise hold its sandbox's single slot forever. */
62
+ const DEFAULT_TIMEOUT_MS = 30 * 60_000;
63
+
64
+ /**
65
+ * Per-pass ceiling on stream bytes. Unbounded, a mirror far behind (manager down, a tool that
66
+ * dumped megabytes) makes every pass time out at the same offset until the strikes kill a live run.
67
+ */
68
+ const POLL_CHUNK_BYTES = 2 * 1024 * 1024;
69
+
70
+ /** The prompt is staged through the exec env carrier, so it inherits that carrier's ceiling. */
71
+ export const PROMPT_MAX_BYTES = EXEC_STDIN_MAX_BYTES;
72
+
73
+ /** How long a cancel waits for claude to flush the result it records on SIGINT before the last tail. */
74
+ const STOP_DRAIN_SECONDS = 5;
75
+
76
+ export interface StartRunOptions {
77
+ resumeSessionId?: string;
78
+ model?: string;
79
+ maxTurns?: number;
80
+ jsonSchema?: string;
81
+ /** Defaults to full autonomy — an MCP run is unattended, and the container is the sandbox. */
82
+ permissionMode?: ClaudePermissionMode;
83
+ timeoutMs?: number;
84
+ }
85
+
86
+ const runDirExpr = (runId: string) => `$h/.codebay/runs/${runId}`;
87
+
88
+ export function runMirrorPath(runId: string): string {
89
+ return join(LOGS_DIR, `run-${runId}.jsonl`);
90
+ }
91
+
92
+ const mirrorSize = (runId: string) => sizeOnDisk(runMirrorPath(runId));
93
+
94
+ /**
95
+ * The script the detached process actually runs. It writes its own PGID first so `stopRun` can
96
+ * signal the whole group, and writes the exit file last so the poller can treat that file's
97
+ * appearance as proof the stream is complete.
98
+ */
99
+ function runScript(runId: string, row: InstanceRow, opts: StartRunOptions): string {
100
+ const flags = [
101
+ '--output-format stream-json',
102
+ '--verbose',
103
+ claudePermissionFlags(opts.permissionMode ?? 'default')
104
+ ];
105
+ if (opts.resumeSessionId) flags.push(`--resume ${quote(opts.resumeSessionId)}`);
106
+ if (opts.model) flags.push(`--model ${quote(opts.model)}`);
107
+ if (opts.maxTurns) flags.push(`--max-turns ${opts.maxTurns}`);
108
+ if (opts.jsonSchema) flags.push('--json-schema "$(cat "$d/schema.json")"');
109
+
110
+ const cd = row.remote_workspace_folder ? `cd ${quote(row.remote_workspace_folder)}` : 'cd "$h"';
111
+
112
+ return (
113
+ `#!/usr/bin/env bash\n` +
114
+ `${HOME_PRELUDE}[ -n "$HOME" ] || export HOME="$h"\n` +
115
+ `d="${runDirExpr(runId)}"\n` +
116
+ // tmp+mv so the poller can never read a half-written exit file and take it for a real code.
117
+ `w(){ printf '%s' "$2" > "$d/$1.tmp"; mv -f "$d/$1.tmp" "$d/$1"; }\n` +
118
+ `die(){ printf 'codebay: %s\\n' "$2" >> "$d/stderr.log"; w exit "$1"; exit "$1"; }\n` +
119
+ // setsid made this the session leader, so $$ is also the PGID stopRun signals.
120
+ `w pgid "$$"\n` +
121
+ // Tells the IDE/terminal launchers not to start a second Claude beside this one. The KILL
122
+ // step of stopScript removes it itself.
123
+ `m="$HOME/${AGENT_RUN_MARKER}"; : > "$m"; trap 'rm -f "$m"' EXIT\n` +
124
+ // A trapped signal only runs its handler and lets bash carry on, so without the explicit
125
+ // exit a cancel that lands during the wait loops below would still go on to launch claude.
126
+ `trap 'w exit 130; exit 130' INT; trap 'w exit 143; exit 143' TERM\n` +
127
+ // Claude Code refuses --dangerously-skip-permissions under uid 0, which is exactly what an
128
+ // instance with no resolved remote_user execs as.
129
+ `[ "$(id -u)" = 0 ] && export IS_SANDBOX=1\n` +
130
+ // A run queued while the instance was still booting must not race the injections that
131
+ // install and authenticate Claude Code.
132
+ `${WAIT_FOR_INJECTIONS}\n` +
133
+ `${cd} || die 127 "workspace folder is missing"\n` +
134
+ `${SOURCE_INJECTED_ENV}\n` +
135
+ `i=0; until command -v claude >/dev/null 2>&1 || [ "$i" -ge ${CLAUDE_BINARY_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done\n` +
136
+ `command -v claude >/dev/null 2>&1 || die 127 "claude is not installed in this container"\n` +
137
+ `claude -p "$(cat "$d/prompt.txt")" ${flags.join(' ')} </dev/null > "$d/stream.jsonl" 2> "$d/stderr.log"\n` +
138
+ `w exit "$?"\n`
139
+ );
140
+ }
141
+
142
+ /**
143
+ * `execInContainer` resolves on the exec stream's `end`, which only fires once every attached fd is
144
+ * closed — so the child must redirect all three or the launch call would hang for the whole run.
145
+ * `setsid` gives it its own process group to signal; a minimal image without it still detaches.
146
+ */
147
+ function launchScript(runId: string): string {
148
+ const d = runDirExpr(runId);
149
+ return (
150
+ `${HOME_PRELUDE}d="${d}"; ` +
151
+ `[ -f "$d/run.sh" ] || { echo "codebay: run script missing" >&2; exit 1; }; ` +
152
+ `rm -f "$d/exit" "$d/stream.jsonl" "$d/stderr.log" "$d/pgid"; ` +
153
+ `if command -v setsid >/dev/null 2>&1; then setsid nohup bash "$d/run.sh" >/dev/null 2>&1 </dev/null & ` +
154
+ `else nohup bash "$d/run.sh" >/dev/null 2>&1 </dev/null & fi`
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Reads the exit file *before* the stream tail. An exit code observed first proves claude was
160
+ * already dead, so the bytes read after it are final; the reverse order would let a run finish
161
+ * between the two reads and report a complete exit alongside a truncated stream.
162
+ */
163
+ function pollScript(runId: string, offset: number): string {
164
+ const d = runDirExpr(runId);
165
+ return (
166
+ `${HOME_PRELUDE}d="${d}"; ` +
167
+ `ex=$(tr -dc '0-9' < "$d/exit" 2>/dev/null); ` +
168
+ `${ALIVE_CHECK(runId)}` +
169
+ `printf '${STATE_MARKER}%s\\t%s\\n' "$ex" "$alive"; ` +
170
+ // run.sh's own trap normally does this; this covers a run that was hard-killed.
171
+ `if [ -n "$ex" ]; then rm -f "$HOME/${AGENT_RUN_MARKER}"; fi; ` +
172
+ // Only worth a read once the run is over; tailing it every pass is pure waste.
173
+ `if [ -n "$ex" ] && [ -s "$d/stderr.log" ]; then printf '${ERR_MARKER}%s\\n' "$(tail -c 2000 "$d/stderr.log" | tr '\\n' ' ')"; fi; ` +
174
+ `printf '%s\\n' '${FETCH_MARKER}'; ` +
175
+ `${tailBlockScript('$d/stream.jsonl', String(offset + 1), POLL_CHUNK_BYTES)}; ` +
176
+ `true`
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Sets `$pg` and `$alive` from `$d/pgid`. Identity, not just `kill -0`: a restarted container
182
+ * reuses low PIDs, so a bare liveness probe would happily report an unrelated process as this run.
183
+ */
184
+ const ALIVE_CHECK = (runId: string): string =>
185
+ `pg=$(tr -dc '0-9' < "$d/pgid" 2>/dev/null); ` +
186
+ `alive=0; if [ -n "$pg" ]; then c=$(tr '\\0' ' ' < "/proc/$pg/cmdline" 2>/dev/null); ` +
187
+ `case "$c" in *${runId}*) alive=1 ;; esac; fi; `;
188
+
189
+ function stopScript(runId: string, signal: 'INT' | 'TERM' | 'KILL'): string {
190
+ const d = runDirExpr(runId);
191
+ return (
192
+ `${HOME_PRELUDE}d="${d}"; ${ALIVE_CHECK(runId)}` +
193
+ // `kill -- -0` would signal every process this user owns, so refuse anything but a live pgid.
194
+ `[ "$alive" = 1 ] && [ "$pg" -gt 1 ] || exit 0; ` +
195
+ // The group kill reaches claude's own children; the bare pid is the setsid-less fallback.
196
+ `kill -${signal} -- "-$pg" 2>/dev/null || kill -${signal} "$pg" 2>/dev/null; ` +
197
+ // KILL skips run.sh's trap, and a cancelled run is never polled again, so nothing else owns it.
198
+ (signal === 'KILL' ? `rm -f "$HOME/${AGENT_RUN_MARKER}" "$d/pgid"; ` : '') +
199
+ `true`
200
+ );
201
+ }
202
+
203
+ /** Waits for the exit file so a tail that follows a signal sees the result claude flushes on its way out. */
204
+ function waitForExitScript(runId: string, seconds: number): string {
205
+ return (
206
+ `${HOME_PRELUDE}d="${runDirExpr(runId)}"; ` +
207
+ `i=0; while [ ! -f "$d/exit" ] && [ "$i" -lt ${seconds} ]; do sleep 1; i=$((i + 1)); done; `
208
+ );
209
+ }
210
+
211
+ /** In-memory per-run cursor: the incomplete trailing line the next chunk has to be prefixed with. */
212
+ interface RunCursor {
213
+ carry: string;
214
+ state: RunStreamState;
215
+ missingStrikes: number;
216
+ /** Streaming, because the byte tail can cut a multi-byte character across two passes. */
217
+ decoder: TextDecoder;
218
+ }
219
+
220
+ interface RunRegistry {
221
+ timer?: ReturnType<typeof setInterval>;
222
+ cursors: Map<string, RunCursor>;
223
+ /** The in-flight pass per run, so an overlapping caller joins it instead of seeing stale state. */
224
+ inFlight: Map<string, Promise<void>>;
225
+ }
226
+
227
+ // Pinned like every other long-lived map here, so dev-mode hot reload doesn't orphan the timer.
228
+ const globalForRuns = globalThis as unknown as { __codebayRuns?: RunRegistry };
229
+ const registry: RunRegistry = (globalForRuns.__codebayRuns ??= {
230
+ cursors: new Map(),
231
+ inFlight: new Map()
232
+ });
233
+
234
+ /**
235
+ * Collapses concurrent passes for one run onto a single promise. Joining rather than skipping is
236
+ * what lets `get_run`, called straight after `run_agent`, observe the launch it triggered.
237
+ */
238
+ function once(runId: string, work: () => Promise<void>): Promise<void> {
239
+ const existing = registry.inFlight.get(runId);
240
+ if (existing) return existing;
241
+ const promise = work().finally(() => registry.inFlight.delete(runId));
242
+ registry.inFlight.set(runId, promise);
243
+ return promise;
244
+ }
245
+
246
+ /** Like `once`, but for work that must run itself rather than join a pass already in flight. */
247
+ async function afterInFlight(runId: string, work: () => Promise<void>): Promise<void> {
248
+ let pending: Promise<void> | undefined;
249
+ while ((pending = registry.inFlight.get(runId))) await pending;
250
+ return once(runId, work);
251
+ }
252
+
253
+ /** Appends a pass's fetched bytes to the mirror; null when the pass carried nothing new. */
254
+ function mirrorChunk(runId: string, stdout: string): Buffer | null {
255
+ const [block] = parseFetchBlocks(stdout);
256
+ if (!block?.base64) return null;
257
+ const bytes = Buffer.from(block.base64, 'base64');
258
+ if (!bytes.length) return null;
259
+ mkdirSync(LOGS_DIR, { recursive: true });
260
+ appendFileSync(runMirrorPath(runId), bytes);
261
+ return bytes;
262
+ }
263
+
264
+ /** The mirror is the record; the cursor is only a cache of it, and can lag it. */
265
+ function stateFromMirror(runId: string, fallback: RunStreamState): RunStreamState {
266
+ try {
267
+ return readRunFile(readFileSync(runMirrorPath(runId), 'utf8'));
268
+ } catch {
269
+ return fallback;
270
+ }
271
+ }
272
+
273
+ /** Rebuilt from the host mirror, so a manager restart mid-run picks up exactly where it left off. */
274
+ function cursorFor(runId: string): RunCursor {
275
+ let cursor = registry.cursors.get(runId);
276
+ if (!cursor) {
277
+ let state = emptyRunState();
278
+ let carry = '';
279
+ try {
280
+ ({ state, carry } = readRunChunk(state, '', readFileSync(runMirrorPath(runId), 'utf8')));
281
+ } catch {
282
+ // No mirror yet — a run that hasn't produced output.
283
+ }
284
+ cursor = { carry, state, missingStrikes: 0, decoder: new TextDecoder('utf-8') };
285
+ registry.cursors.set(runId, cursor);
286
+ }
287
+ return cursor;
288
+ }
289
+
290
+ /**
291
+ * Set by `instances.server.ts` at import time. A callback rather than a direct import because the
292
+ * broadcast must not go through `triggerReconcile` — that runs a `docker inspect` per instance, and
293
+ * this fires every few seconds per active run.
294
+ */
295
+ let onRunChanged: ((runId: string) => void) | undefined;
296
+ export function setRunChangeHook(hook: (runId: string) => void): void {
297
+ onRunChanged = hook;
298
+ }
299
+
300
+ /** The one projection of a run row every reader shares, so the Agent log and MCP can't drift apart. */
301
+ export function runDetail(row: AgentRunRow) {
302
+ return {
303
+ id: row.id,
304
+ instance_id: row.instance_id,
305
+ status: row.status,
306
+ prompt: row.prompt,
307
+ session_id: row.session_id,
308
+ resume_session_id: row.resume_session_id,
309
+ model: row.model,
310
+ requested_model: requestedModel(row),
311
+ last_activity: row.last_activity,
312
+ result: row.result,
313
+ structured_output: row.structured_output
314
+ ? (JSON.parse(row.structured_output) as unknown)
315
+ : null,
316
+ rejected_structured_output: row.rejected_structured_output
317
+ ? (JSON.parse(row.rejected_structured_output) as unknown)
318
+ : null,
319
+ is_error: row.is_error === 1,
320
+ error: row.error,
321
+ exit_code: row.exit_code,
322
+ num_turns: row.num_turns,
323
+ cost_usd: row.cost_usd,
324
+ duration_ms: row.duration_ms,
325
+ created_at: row.created_at,
326
+ started_at: row.started_at,
327
+ finished_at: row.finished_at
328
+ };
329
+ }
330
+
331
+ export function runSummary(row: AgentRunRow): AgentRunSummary {
332
+ const { id, instance_id, status, last_activity, started_at, finished_at, is_error } =
333
+ runDetail(row);
334
+ return { id, instance_id, status, last_activity, started_at, finished_at, is_error };
335
+ }
336
+
337
+ /** Seeds a freshly connected socket, the way `currentHealthSnapshots` does for health. */
338
+ export function currentRunSummaries(): AgentRunSummary[] {
339
+ return openRuns().map(runSummary);
340
+ }
341
+
342
+ function finish(runId: string, patch: Parameters<typeof updateRun>[1]): void {
343
+ updateRun(runId, { ...patch, finished_at: Date.now() });
344
+ registry.cursors.delete(runId);
345
+ onRunChanged?.(runId);
346
+ }
347
+
348
+ export function startRunTimer(): void {
349
+ if (registry.timer) return;
350
+ registry.timer = setInterval(() => {
351
+ void tick();
352
+ }, POLL_MS);
353
+ // A pending run must never be the reason the process stays alive.
354
+ registry.timer.unref?.();
355
+ }
356
+
357
+ function stopRunTimer(): void {
358
+ if (!registry.timer) return;
359
+ clearInterval(registry.timer);
360
+ registry.timer = undefined;
361
+ }
362
+
363
+ async function tick(): Promise<void> {
364
+ const rows = openRuns();
365
+ if (!rows.length) {
366
+ stopRunTimer();
367
+ return;
368
+ }
369
+ await Promise.all(rows.map((row) => advance(row)));
370
+ }
371
+
372
+ /** Re-attaches pollers to runs that were in flight when the manager went down. */
373
+ export function resumeRuns(): void {
374
+ if (openRuns().length) startRunTimer();
375
+ }
376
+
377
+ async function advance(row: AgentRunRow): Promise<void> {
378
+ const instance = getInstance(row.instance_id);
379
+ if (!instance) {
380
+ finish(row.id, { status: 'error', error: 'sandbox no longer exists', is_error: 1 });
381
+ return;
382
+ }
383
+ if (row.status === 'queued') {
384
+ if (instance.status === 'creating') return; // Still booting; the launcher waits it out.
385
+ if (instance.status !== 'running' || !instance.container_id) {
386
+ finish(row.id, {
387
+ status: 'error',
388
+ error: `sandbox is ${instance.status}, not running`,
389
+ is_error: 1
390
+ });
391
+ return;
392
+ }
393
+ await launch(row, instance);
394
+ return;
395
+ }
396
+ if (!instance.container_id) {
397
+ finish(row.id, { status: 'error', error: 'sandbox has no container', is_error: 1 });
398
+ return;
399
+ }
400
+ await poll(row, instance);
401
+ }
402
+
403
+ function optionsOf(row: AgentRunRow): StartRunOptions {
404
+ try {
405
+ return row.options ? (JSON.parse(row.options) as StartRunOptions) : {};
406
+ } catch {
407
+ return {};
408
+ }
409
+ }
410
+
411
+ /** The alias or id the caller asked for; null when the run took the sandbox default. */
412
+ export function requestedModel(row: AgentRunRow): string | null {
413
+ return optionsOf(row).model ?? null;
414
+ }
415
+
416
+ /**
417
+ * Staging happens here rather than in `startRun` because a run may be queued against an instance
418
+ * that is still booting and has no container to write into yet.
419
+ */
420
+ async function stage(row: AgentRunRow, instance: InstanceRow): Promise<string | null> {
421
+ const target = targetFor(instance);
422
+ const dir = runDirExpr(row.id);
423
+ const opts = optionsOf(row);
424
+ const wrote = await writeContainerFile(
425
+ target,
426
+ { dir, name: 'prompt.txt', mode: '600' },
427
+ row.prompt
428
+ );
429
+ if (!wrote.ok) return `could not stage the prompt: ${wrote.error}`;
430
+ if (opts.jsonSchema) {
431
+ const schema = await writeContainerFile(
432
+ target,
433
+ { dir, name: 'schema.json', mode: '600' },
434
+ opts.jsonSchema
435
+ );
436
+ if (!schema.ok) return `could not stage the schema: ${schema.error}`;
437
+ }
438
+ const script = await writeContainerFile(
439
+ target,
440
+ { dir, name: 'run.sh', mode: '700' },
441
+ runScript(row.id, instance, { ...opts, resumeSessionId: row.resume_session_id ?? undefined })
442
+ );
443
+ return script.ok ? null : `could not stage the run script: ${script.error}`;
444
+ }
445
+
446
+ function launch(row: AgentRunRow, instance: InstanceRow): Promise<void> {
447
+ return once(row.id, async () => {
448
+ const staged = await stage(row, instance);
449
+ if (staged) {
450
+ finish(row.id, { status: 'error', error: staged, is_error: 1 });
451
+ return;
452
+ }
453
+ // stopRun doesn't go through once(), so a cancel can land while staging is still in flight.
454
+ if (getRun(row.id)?.status !== 'queued') return;
455
+ // The script detaches and returns at once, so anything slower is a wedged stream — and this
456
+ // runs under once(), where a hung exec would also hang every get_run for the row.
457
+ const res = await execInContainer(targetFor(instance), {
458
+ script: launchScript(row.id),
459
+ timeoutMs: 30_000
460
+ });
461
+ if (!res.ok) {
462
+ finish(row.id, {
463
+ status: 'error',
464
+ error: `could not start run: ${res.error ?? 'unknown error'}`,
465
+ is_error: 1
466
+ });
467
+ return;
468
+ }
469
+ if (getRun(row.id)?.status !== 'queued') {
470
+ // Cancelled during the launch exec: the process is real now, so put it down.
471
+ void execInContainer(targetFor(instance), {
472
+ script: stopScript(row.id, 'TERM'),
473
+ timeoutMs: 15_000
474
+ });
475
+ return;
476
+ }
477
+ updateRun(row.id, { status: 'running', started_at: Date.now() });
478
+ onRunChanged?.(row.id);
479
+ });
480
+ }
481
+
482
+ function poll(row: AgentRunRow, instance: InstanceRow): Promise<void> {
483
+ return once(row.id, async () => {
484
+ const cursor = cursorFor(row.id);
485
+ const res = await execInContainer(targetFor(instance), {
486
+ script: pollScript(row.id, mirrorSize(row.id)),
487
+ capture: true,
488
+ timeoutMs: 30_000
489
+ });
490
+ if (!res.ok) {
491
+ // A container that went away mid-run can never produce an exit file.
492
+ if (++cursor.missingStrikes >= MISSING_PROCESS_STRIKES) {
493
+ finish(row.id, {
494
+ status: 'error',
495
+ error: `lost contact with the sandbox: ${res.error ?? 'unknown error'}`,
496
+ is_error: 1
497
+ });
498
+ }
499
+ return;
500
+ }
501
+
502
+ // Read in the script's own order: the state line was captured before these bytes, so an
503
+ // exit code here guarantees the stream below it is complete.
504
+ const [exitRaw = '', aliveRaw = ''] = (markerLine(res.stdout, STATE_MARKER) ?? '')
505
+ .split('\t')
506
+ .map((f) => f.trim());
507
+
508
+ const bytes = mirrorChunk(row.id, res.stdout);
509
+ const changed = bytes !== null;
510
+ if (bytes) {
511
+ const text = cursor.decoder.decode(bytes, { stream: true });
512
+ const next = readRunChunk(cursor.state, cursor.carry, text);
513
+ cursor.state = next.state;
514
+ cursor.carry = next.carry;
515
+ }
516
+
517
+ // The row may have been cancelled while the exec was out; the mirror still got its bytes.
518
+ if (getRun(row.id)?.status !== 'running') return;
519
+
520
+ // The terminal fold reads the whole mirror rather than trusting the cursor: a chunk this
521
+ // process never fetched (a second manager on the same DATA_DIR did) is still on disk.
522
+ const state = exitRaw ? stateFromMirror(row.id, cursor.state) : cursor.state;
523
+ if (changed) {
524
+ updateRun(row.id, {
525
+ session_id: state.sessionId,
526
+ model: state.model,
527
+ last_activity: state.lastActivity,
528
+ num_turns: state.numTurns,
529
+ cost_usd: state.costUsd
530
+ });
531
+ onRunChanged?.(row.id);
532
+ }
533
+
534
+ if (exitRaw) {
535
+ const exitCode = Number.parseInt(exitRaw, 10);
536
+ const failed = state.isError || exitCode !== 0;
537
+ const stderr = markerLine(res.stdout, ERR_MARKER)?.trim();
538
+ finish(row.id, {
539
+ status: failed ? 'error' : 'done',
540
+ exit_code: exitCode,
541
+ is_error: failed ? 1 : 0,
542
+ result: state.result,
543
+ structured_output: state.structuredOutput,
544
+ rejected_structured_output: state.rejectedStructuredOutput,
545
+ duration_ms: state.durationMs ?? (row.started_at ? Date.now() - row.started_at : null),
546
+ num_turns: state.numTurns,
547
+ cost_usd: state.costUsd,
548
+ session_id: state.sessionId,
549
+ model: state.model,
550
+ last_activity: state.lastActivity,
551
+ // A schema failure gets its own marker so a caller can tell "answered but the payload
552
+ // never validated" (result holds that answer) from "claude crashed"; otherwise the
553
+ // stream's own result text beats stderr, which only fills in on an outright crash.
554
+ error: failed
555
+ ? state.structuredOutputFailed
556
+ ? STRUCTURED_OUTPUT_FAILED_ERROR
557
+ : stderr || state.result || `claude exited ${exitCode}`
558
+ : null
559
+ });
560
+ return;
561
+ }
562
+
563
+ const timeoutMs = optionsOf(row).timeoutMs ?? DEFAULT_TIMEOUT_MS;
564
+ if (row.started_at && Date.now() - row.started_at > timeoutMs) {
565
+ await stopRun(row.id, `timed out after ${Math.round(timeoutMs / 60_000)} minutes`, {
566
+ failed: true
567
+ });
568
+ return;
569
+ }
570
+
571
+ // `alive` covers both a dead process and a missing pgid file: either way, nothing is going
572
+ // to write the exit file, so the container must have restarted under the run.
573
+ if (aliveRaw !== '1') {
574
+ if (++cursor.missingStrikes >= MISSING_PROCESS_STRIKES) {
575
+ finish(row.id, {
576
+ status: 'error',
577
+ error: 'the run process is gone — the sandbox was probably restarted',
578
+ is_error: 1,
579
+ result: state.result
580
+ });
581
+ }
582
+ return;
583
+ }
584
+ cursor.missingStrikes = 0;
585
+ });
586
+ }
587
+
588
+ /** Forces one pass now, so a caller polling `get_run` isn't held to the background cadence. */
589
+ export async function pollRunNow(runId: string): Promise<AgentRunRow | null> {
590
+ const row = getRun(runId);
591
+ if (!row || (row.status !== 'running' && row.status !== 'queued')) return row;
592
+ await advance(row);
593
+ return getRun(runId);
594
+ }
595
+
596
+ export function startRun(
597
+ instance: InstanceRow,
598
+ prompt: string,
599
+ opts: StartRunOptions = {}
600
+ ): AgentRunRow {
601
+ if (!prompt.trim()) throw new Error('prompt is required');
602
+ const promptBytes = Buffer.byteLength(prompt, 'utf8');
603
+ if (promptBytes > PROMPT_MAX_BYTES) {
604
+ throw new Error(
605
+ `prompt is ${promptBytes} bytes; a run carries at most ${PROMPT_MAX_BYTES} — put the bulk in a ` +
606
+ 'workspace file and refer to it'
607
+ );
608
+ }
609
+ if (instance.status === 'error') throw new Error('sandbox failed to build');
610
+ // Claude Code keeps one session directory per project, so two concurrent runs would interleave.
611
+ const open = openRunFor(instance.id);
612
+ if (open) throw new Error(`sandbox already has an active run (${open.id})`);
613
+
614
+ const id = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
615
+
616
+ const row: AgentRunRow = {
617
+ id,
618
+ instance_id: instance.id,
619
+ prompt,
620
+ status: 'queued',
621
+ session_id: null,
622
+ model: null,
623
+ resume_session_id: opts.resumeSessionId ?? null,
624
+ options: JSON.stringify(opts),
625
+ result: null,
626
+ structured_output: null,
627
+ rejected_structured_output: null,
628
+ last_activity: null,
629
+ is_error: 0,
630
+ exit_code: null,
631
+ cost_usd: null,
632
+ duration_ms: null,
633
+ num_turns: null,
634
+ error: null,
635
+ created_at: Date.now(),
636
+ started_at: null,
637
+ finished_at: null
638
+ };
639
+ insertRun(row);
640
+ startRunTimer();
641
+ onRunChanged?.(id);
642
+ // A sandbox that is already up shouldn't wait a poll interval to get going.
643
+ if (instance.status === 'running') void advance(row);
644
+ return row;
645
+ }
646
+
647
+ /**
648
+ * SIGINT first, because Claude Code ends the turn cleanly and records a result on it where SIGTERM
649
+ * leaves the turn unfinished; `failed` marks a stop the caller didn't ask for (a timeout) as an error.
650
+ */
651
+ export async function stopRun(
652
+ runId: string,
653
+ reason = 'cancelled by the caller',
654
+ { failed = false } = {}
655
+ ): Promise<AgentRunRow> {
656
+ const row = getRun(runId);
657
+ if (!row) throw new Error('Run not found');
658
+ if (row.status !== 'running' && row.status !== 'queued') return row;
659
+ // The mirror, not the cursor: after a manager restart no cursor exists until the first pass.
660
+ const state = stateFromMirror(runId, registry.cursors.get(runId)?.state ?? emptyRunState());
661
+ // Settled before the signal goes out, so a caller that doesn't await (rebuild) still sees the
662
+ // slot free and the poller stops exec'ing into a container that is on its way out.
663
+ finish(runId, {
664
+ status: failed ? 'error' : 'cancelled',
665
+ is_error: failed ? 1 : 0,
666
+ error: reason,
667
+ result: state.result ?? null,
668
+ last_activity: state.lastActivity ?? row.last_activity,
669
+ duration_ms: row.started_at ? Date.now() - row.started_at : null
670
+ });
671
+ const instance = getInstance(row.instance_id);
672
+ if (instance?.container_id) {
673
+ const target = targetFor(instance);
674
+ // SIGINT lets claude flush its result event and exit cleanly; the rest is for a wedged process.
675
+ await execInContainer(target, { script: stopScript(runId, 'INT'), timeoutMs: 15_000 });
676
+ for (const [delay, signal] of [
677
+ [STOP_DRAIN_SECONDS * 1000, 'TERM'],
678
+ [STOP_DRAIN_SECONDS * 2000, 'KILL']
679
+ ] as const) {
680
+ // stopScript itself checks the process is still this run, so a clean exit makes these no-ops.
681
+ setTimeout(() => {
682
+ void execInContainer(target, { script: stopScript(runId, signal), timeoutMs: 15_000 });
683
+ }, delay).unref?.();
684
+ }
685
+ await drainAfterStop(runId, instance);
686
+ }
687
+ return getRun(runId)!;
688
+ }
689
+
690
+ /**
691
+ * The poller never visits a cancelled row again, so without this last tail the result claude flushed
692
+ * on SIGINT would sit in the container and never reach the row or the mirror.
693
+ */
694
+ async function drainAfterStop(runId: string, instance: InstanceRow): Promise<void> {
695
+ await afterInFlight(runId, async () => {
696
+ const res = await execInContainer(targetFor(instance), {
697
+ script: waitForExitScript(runId, STOP_DRAIN_SECONDS) + pollScript(runId, mirrorSize(runId)),
698
+ capture: true,
699
+ timeoutMs: (STOP_DRAIN_SECONDS + 25) * 1000
700
+ });
701
+ if (!res.ok || !mirrorChunk(runId, res.stdout)) return;
702
+ const state = stateFromMirror(runId, emptyRunState());
703
+ updateRun(runId, {
704
+ session_id: state.sessionId,
705
+ model: state.model,
706
+ last_activity: state.lastActivity,
707
+ num_turns: state.numTurns,
708
+ cost_usd: state.costUsd,
709
+ result: state.result
710
+ });
711
+ onRunChanged?.(runId);
712
+ });
713
+ }
714
+
715
+ /**
716
+ * The run's steps, rendered by the instance page's Agent log. Read from the host mirror rather than
717
+ * the container, so it still works after the sandbox is gone.
718
+ */
719
+ export function runTimeline(runId: string, limit = 500): RunTimelineEntry[] {
720
+ try {
721
+ return parseRunTimeline(readFileSync(runMirrorPath(runId), 'utf8')).slice(-limit);
722
+ } catch {
723
+ return [];
724
+ }
725
+ }
726
+
727
+ /** The trailing lines of a run's mirrored transcript, for `get_logs`. */
728
+ export function readRunLog(runId: string, tailLines = 200): string {
729
+ try {
730
+ const lines = readFileSync(runMirrorPath(runId), 'utf8').split('\n').filter(Boolean);
731
+ return lines.slice(-tailLines).join('\n');
732
+ } catch {
733
+ return '';
734
+ }
735
+ }