@runuai/host 0.4.3 → 0.5.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.
@@ -0,0 +1,15 @@
1
+ -- ADR-061: durable agent sessions. One row per (task, agent) tracking the
2
+ -- live in-container runner and how far the host has consumed its outbox —
3
+ -- the byte offset is what makes a host restart resume instead of respawn.
4
+ CREATE TABLE `host_agent_sessions` (
5
+ `task_id` text NOT NULL,
6
+ `agent_id` text NOT NULL,
7
+ `session_dir` text NOT NULL,
8
+ `container_name` text NOT NULL,
9
+ `kind` text NOT NULL,
10
+ `outbox_offset` integer DEFAULT 0 NOT NULL,
11
+ `status` text DEFAULT 'running' NOT NULL,
12
+ `created_at` integer NOT NULL,
13
+ `updated_at` integer NOT NULL,
14
+ PRIMARY KEY(`task_id`, `agent_id`)
15
+ );
@@ -64,6 +64,13 @@
64
64
  "when": 1779900009000,
65
65
  "tag": "0008_host_mcp_connections",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1779900010000,
72
+ "tag": "0009_host_agent_sessions",
73
+ "breakpoints": true
67
74
  }
68
75
  ]
69
76
  }
package/db/schema.ts CHANGED
@@ -121,6 +121,39 @@ export type NewHostProjectEnv = typeof hostProjectEnv.$inferInsert;
121
121
  // is sealed with the host master key in single-column `ct.nonce` base64 form
122
122
  // (same packing as host_project_env). The cloud only ever holds the non-secret
123
123
  // metadata; acks never echo secrets back.
124
+ // ---------------------------------------------------------------------------
125
+ // host_agent_sessions — ADR-061 durable agent sessions. One row per
126
+ // (task, agent): where the live in-container runner's session dir is and how
127
+ // far the host has consumed its outbox. The byte offset is what turns a host
128
+ // restart into "resume tailing" instead of "kill and respawn"; attachability
129
+ // itself is judged live off the runner's heartbeat file, not this row.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export const hostAgentSessions = sqliteTable(
133
+ "host_agent_sessions",
134
+ {
135
+ taskId: text("task_id").notNull(),
136
+ agentId: text("agent_id").notNull(),
137
+ // Host-side absolute path of the session dir (on the workspace mount,
138
+ // identical in-container — task-up bind-mounts the workspace at its own
139
+ // host path).
140
+ sessionDir: text("session_dir").notNull(),
141
+ containerName: text("container_name").notNull(),
142
+ kind: text("kind").notNull(), // "claude" | "codex" | ...
143
+ outboxOffset: integer("outbox_offset", { mode: "number" })
144
+ .notNull()
145
+ .default(0),
146
+ status: text("status").notNull().default("running"), // running|closed
147
+ createdAt: integer("created_at", { mode: "number" }).notNull(),
148
+ updatedAt: integer("updated_at", { mode: "number" }).notNull(),
149
+ },
150
+ (t) => ({
151
+ pk: primaryKey({ columns: [t.taskId, t.agentId] }),
152
+ }),
153
+ );
154
+
155
+ export type HostAgentSession = typeof hostAgentSessions.$inferSelect;
156
+
124
157
  export const mcpConnections = sqliteTable("host_mcp_connections", {
125
158
  id: text("id").primaryKey(), // cloud uai_mcp_connections id
126
159
  userId: text("user_id").notNull(),
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { newId } from "../ulid";
24
- import { dockerExecArgs, LineProcess } from "./proc";
24
+ import { createAgentTransport, type LineTransport } from "./transport";
25
25
  import { register } from "./registry";
26
26
  import type {
27
27
  AgentEvent,
@@ -193,11 +193,12 @@ export class ClaudeSession implements AgentSession {
193
193
  readonly agentId: string;
194
194
  readonly kind: AgentKind = "claude";
195
195
 
196
- private readonly proc: LineProcess;
196
+ private readonly proc: LineTransport;
197
197
  private readonly handlers = new Set<AgentEventHandler>();
198
198
  private closed = false;
199
199
 
200
200
  constructor(args: {
201
+ taskId: string;
201
202
  agent: RosterAgent;
202
203
  containerName: string;
203
204
  systemPreamble: string;
@@ -231,16 +232,20 @@ export class ClaudeSession implements AgentSession {
231
232
  // it needs CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or an
232
233
  // API key. These live in the host-agent's env (never the cloud, ADR-015);
233
234
  // only ones actually set are forwarded.
234
- const { command, args: argv } = dockerExecArgs(
235
- args.containerName,
236
- "claude",
235
+ // ADR-061: durable by default the CLI is owned by an in-container
236
+ // runner and survives host restarts (attach resumes it); legacy pipes
237
+ // behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
238
+ // live runner can be re-attached (allowAttach).
239
+ this.proc = createAgentTransport({
240
+ taskId: args.taskId,
241
+ agentId: this.agentId,
242
+ containerName: args.containerName,
243
+ cli: "claude",
237
244
  cliArgs,
238
- ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
239
- args.agentEnv ?? {},
240
- );
241
- this.proc = new LineProcess({
242
- command,
243
- args: argv,
245
+ passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
246
+ explicitEnv: args.agentEnv ?? {},
247
+ allowAttach: true,
248
+ kind: "claude",
244
249
  debugLabel: `claude:${this.agentId}`,
245
250
  });
246
251
  this.proc.onLine((line) => {
@@ -349,6 +354,6 @@ register({
349
354
  process.env.ANTHROPIC_API_KEY ||
350
355
  process.env.ANTHROPIC_AUTH_TOKEN,
351
356
  ),
352
- create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
353
- new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
357
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
358
+ new ClaudeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
354
359
  });
@@ -33,7 +33,7 @@ import { homedir } from "node:os";
33
33
  import { join } from "node:path";
34
34
 
35
35
  import { newId } from "../ulid";
36
- import { dockerExecArgs, LineProcess } from "./proc";
36
+ import { createAgentTransport, type LineTransport } from "./transport";
37
37
  import { register } from "./registry";
38
38
  import type {
39
39
  AgentEvent,
@@ -213,7 +213,7 @@ export class CodexSession implements AgentSession {
213
213
  readonly agentId: string;
214
214
  readonly kind: AgentKind = "codex";
215
215
 
216
- private readonly proc: LineProcess;
216
+ private readonly proc: LineTransport;
217
217
  private readonly handlers = new Set<AgentEventHandler>();
218
218
  private readonly systemPreamble: string;
219
219
  private closed = false;
@@ -231,6 +231,7 @@ export class CodexSession implements AgentSession {
231
231
  private readonly ready: Promise<void>;
232
232
 
233
233
  constructor(args: {
234
+ taskId: string;
234
235
  agent: RosterAgent;
235
236
  containerName: string;
236
237
  systemPreamble: string;
@@ -251,16 +252,22 @@ export class CodexSession implements AgentSession {
251
252
  codexArgs.push("-c", `model_reasoning_effort=${args.agent.effort}`);
252
253
  }
253
254
  codexArgs.push("app-server");
254
- const { command, args: argv } = dockerExecArgs(
255
- args.containerName,
256
- "codex",
257
- codexArgs,
258
- [],
259
- args.agentEnv ?? {},
260
- );
261
- this.proc = new LineProcess({
262
- command,
263
- args: argv,
255
+ // ADR-061: the CLI is owned by an in-container runner (durable across
256
+ // host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
257
+ // re-handshakes per host process (initialize + thread/start live in this
258
+ // object), so a leftover runner is never attached — allowAttach:false
259
+ // stops it and spawns fresh. Its final output before the stop still
260
+ // reaches the cloud via the outbox.
261
+ this.proc = createAgentTransport({
262
+ taskId: args.taskId,
263
+ agentId: this.agentId,
264
+ containerName: args.containerName,
265
+ cli: "codex",
266
+ cliArgs: codexArgs,
267
+ passEnv: [],
268
+ explicitEnv: args.agentEnv ?? {},
269
+ allowAttach: false,
270
+ kind: "codex",
264
271
  debugLabel: `codex:${this.agentId}`,
265
272
  });
266
273
  this.proc.onLine((line) => this.onLine(line));
@@ -538,6 +545,6 @@ register({
538
545
  existsSync(
539
546
  join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
540
547
  ),
541
- create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
542
- new CodexSession({ agent, containerName, systemPreamble, agentEnv }),
548
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
549
+ new CodexSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
543
550
  });
@@ -0,0 +1,306 @@
1
+ /**
2
+ * DurableProcess (ADR-061) — LineProcess's durable twin. Instead of holding
3
+ * the agent CLI's pipes, it talks to the in-container runner
4
+ * (runner/runner.mjs) through two append-only JSONL files on the
5
+ * task-workspace bind mount:
6
+ *
7
+ * writeLine() → append to <sessionDir>/inbox.jsonl
8
+ * onLine() ← poll-tail <sessionDir>/outbox.jsonl from a byte offset
9
+ *
10
+ * The CLI's lifetime is decoupled from this object: dropping it (host
11
+ * restart) leaves the runner and CLI running; `attach()` with the persisted
12
+ * outbox offset resumes exactly where consumption stopped. `close()` is the
13
+ * real teardown — it sends the runner a stop control.
14
+ *
15
+ * Exit is observed via the runner's `{"__uai":"exit"}` meta line, with a
16
+ * stale-heartbeat check as the backstop for a killed container. Meta lines
17
+ * never reach onLine handlers — adapters see the same protocol stream
18
+ * LineProcess gave them.
19
+ */
20
+
21
+ import { spawn } from "node:child_process";
22
+ import {
23
+ closeSync,
24
+ fstatSync,
25
+ mkdirSync,
26
+ openSync,
27
+ promises as fsp,
28
+ readSync,
29
+ statSync,
30
+ } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ import type { ExitHandler, LineHandler } from "./proc";
35
+
36
+ const POLL_MS = 75;
37
+ /** Runner beats every 5 s; 25 s of silence without an exit meta = dead. */
38
+ const HEARTBEAT_STALE_MS = 25_000;
39
+ /** After close(), how long to wait for the exit meta before giving up. */
40
+ const CLOSE_GRACE_MS = 8_000;
41
+
42
+ /** Host-side absolute path of the bundled in-container runner script. */
43
+ export function runnerScriptPath(): string {
44
+ return fileURLToPath(new URL("../../runner/runner.mjs", import.meta.url));
45
+ }
46
+
47
+ export interface DurableProcessOptions {
48
+ /** Session dir as seen FROM THE HOST (on the workspace bind mount). */
49
+ hostSessionDir: string;
50
+ /**
51
+ * Command that launches the runner (e.g. `docker exec -d … node
52
+ * /opt/uai/runner.mjs <containerSessionDir> -- claude …`). Omit to ATTACH
53
+ * to a runner that is already alive (boot reconciliation).
54
+ */
55
+ spawnCommand?: { command: string; args: string[] };
56
+ /** Resume consumption from this outbox byte offset (attach path). */
57
+ initialOutboxOffset?: number;
58
+ /** Called after each poll batch whose lines were delivered — persist this
59
+ * offset so a host restart resumes here instead of replaying. */
60
+ onOffsetAdvance?: (offset: number) => void;
61
+ /** Called the moment close() is requested (before the runner has actually
62
+ * died) — lets the owner mark the session unattachable immediately, so a
63
+ * re-create during the teardown grace window spawns fresh instead of
64
+ * attaching to a dying runner. */
65
+ onCloseRequested?: () => void;
66
+ /** Mirrors LineProcess: log raw traffic when UAI_DEBUG_AGENTS is set. */
67
+ debugLabel?: string;
68
+ }
69
+
70
+ interface RunnerMeta {
71
+ __uai: string;
72
+ code?: number | null;
73
+ stderrTail?: string;
74
+ }
75
+
76
+ export class DurableProcess {
77
+ private readonly dir: string;
78
+ private readonly outboxPath: string;
79
+ private readonly inboxPath: string;
80
+ private readonly heartbeatPath: string;
81
+
82
+ private readonly lineHandlers = new Set<LineHandler>();
83
+ private readonly exitHandlers = new Set<ExitHandler>();
84
+
85
+ private offset: number;
86
+ private lineBuf = "";
87
+ private stderrBuf = "";
88
+ private closed = false;
89
+ private detached = false;
90
+ private sawSpawnMeta = false;
91
+ private readonly startedAt = Date.now();
92
+ private poller: ReturnType<typeof setInterval> | null = null;
93
+ private closeTimer: ReturnType<typeof setTimeout> | null = null;
94
+ private inboxChain: Promise<void> = Promise.resolve();
95
+ private readonly onOffsetAdvance: ((offset: number) => void) | null;
96
+ private readonly onCloseRequested: (() => void) | null;
97
+ private readonly debug: string | null;
98
+
99
+ constructor(opts: DurableProcessOptions) {
100
+ this.dir = opts.hostSessionDir;
101
+ this.outboxPath = join(this.dir, "outbox.jsonl");
102
+ this.inboxPath = join(this.dir, "inbox.jsonl");
103
+ this.heartbeatPath = join(this.dir, "heartbeat");
104
+ this.offset = opts.initialOutboxOffset ?? 0;
105
+ this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
106
+ this.onCloseRequested = opts.onCloseRequested ?? null;
107
+ this.debug =
108
+ opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
109
+
110
+ mkdirSync(this.dir, { recursive: true });
111
+
112
+ if (opts.spawnCommand) {
113
+ if (this.debug) {
114
+ this.log(
115
+ `spawn: ${opts.spawnCommand.command} ${opts.spawnCommand.args.join(" ")}`,
116
+ );
117
+ }
118
+ const child = spawn(opts.spawnCommand.command, opts.spawnCommand.args, {
119
+ stdio: ["ignore", "ignore", "pipe"],
120
+ });
121
+ child.stderr?.setEncoding("utf8");
122
+ child.stderr?.on("data", (chunk: string) => {
123
+ this.stderrBuf = (this.stderrBuf + chunk).slice(-8192);
124
+ });
125
+ // `docker exec -d` exits 0 immediately on success; non-zero means the
126
+ // runner never started (bad container, bad mount) — fail loudly now.
127
+ child.on("exit", (code) => {
128
+ if (code !== null && code !== 0) this.finish(null);
129
+ });
130
+ child.on("error", () => this.finish(null));
131
+ child.unref();
132
+ } else {
133
+ this.sawSpawnMeta = true; // attach: the runner pre-exists
134
+ }
135
+
136
+ this.poller = setInterval(() => this.poll(), POLL_MS);
137
+ }
138
+
139
+ private log(msg: string): void {
140
+ console.error(`[uai-agent ${this.debug}] ${msg}`);
141
+ }
142
+
143
+ // ---- outbox tail ---------------------------------------------------------
144
+
145
+ private poll(): void {
146
+ if (this.closed || this.detached) return;
147
+ let fd: number;
148
+ try {
149
+ fd = openSync(this.outboxPath, "r");
150
+ } catch {
151
+ this.checkLiveness();
152
+ return; // outbox not created yet
153
+ }
154
+ const before = this.offset;
155
+ try {
156
+ const size = fstatSync(fd).size;
157
+ while (this.offset < size) {
158
+ const len = Math.min(size - this.offset, 256 * 1024);
159
+ const buf = Buffer.alloc(len);
160
+ const read = readSync(fd, buf, 0, len, this.offset);
161
+ if (read <= 0) break;
162
+ this.offset += read;
163
+ this.lineBuf += buf.toString("utf8", 0, read);
164
+ this.drainLines();
165
+ }
166
+ } finally {
167
+ closeSync(fd);
168
+ }
169
+ if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
170
+ try {
171
+ this.onOffsetAdvance(this.offset);
172
+ } catch {
173
+ // Persistence hiccup — the next batch retries with a larger offset.
174
+ }
175
+ }
176
+ this.checkLiveness();
177
+ }
178
+
179
+ private drainLines(): void {
180
+ let nl: number;
181
+ while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
182
+ const line = this.lineBuf.slice(0, nl).trim();
183
+ this.lineBuf = this.lineBuf.slice(nl + 1);
184
+ if (line.length === 0) continue;
185
+ if (line.startsWith('{"__uai"')) {
186
+ this.handleMeta(line);
187
+ continue;
188
+ }
189
+ if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
190
+ for (const h of this.lineHandlers) {
191
+ try {
192
+ h(line);
193
+ } catch {
194
+ // A broken handler must not wedge the tail loop.
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ private handleMeta(line: string): void {
201
+ let meta: RunnerMeta;
202
+ try {
203
+ meta = JSON.parse(line) as RunnerMeta;
204
+ } catch {
205
+ return;
206
+ }
207
+ if (meta.__uai === "spawn") {
208
+ this.sawSpawnMeta = true;
209
+ } else if (meta.__uai === "exit") {
210
+ if (typeof meta.stderrTail === "string" && meta.stderrTail) {
211
+ this.stderrBuf = meta.stderrTail.slice(-8192);
212
+ }
213
+ if (this.debug) this.log(`exit meta: code ${meta.code ?? null}`);
214
+ this.finish(typeof meta.code === "number" ? meta.code : null);
215
+ }
216
+ }
217
+
218
+ /** Backstop: no exit meta, but the runner stopped beating → it's gone. */
219
+ private checkLiveness(): void {
220
+ const path = this.sawSpawnMeta ? this.heartbeatPath : null;
221
+ if (!path) {
222
+ // Runner never wrote anything; give the spawn a grace window.
223
+ if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
224
+ return;
225
+ }
226
+ try {
227
+ const age = Date.now() - statSync(path).mtimeMs;
228
+ if (age > HEARTBEAT_STALE_MS) this.finish(null);
229
+ } catch {
230
+ if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
231
+ }
232
+ }
233
+
234
+ private finish(code: number | null): void {
235
+ if (this.closed) return;
236
+ this.closed = true;
237
+ if (this.poller) clearInterval(this.poller);
238
+ if (this.closeTimer) clearTimeout(this.closeTimer);
239
+ for (const h of this.exitHandlers) h(code);
240
+ }
241
+
242
+ // ---- LineProcess-compatible surface ---------------------------------------
243
+
244
+ onLine(handler: LineHandler): void {
245
+ this.lineHandlers.add(handler);
246
+ }
247
+
248
+ onExit(handler: ExitHandler): void {
249
+ this.exitHandlers.add(handler);
250
+ }
251
+
252
+ /** Serialise `value` as one JSONL line appended to the runner's inbox. */
253
+ writeLine(value: unknown): void {
254
+ if (this.closed || this.detached) return;
255
+ const json = JSON.stringify(value);
256
+ if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
257
+ // Chain appends so concurrent writes can't interleave bytes.
258
+ this.inboxChain = this.inboxChain
259
+ .then(() => fsp.appendFile(this.inboxPath, `${json}\n`))
260
+ .catch(() => {
261
+ // Disk error — liveness checks will surface a dead session.
262
+ });
263
+ }
264
+
265
+ get stderrTail(): string {
266
+ return this.stderrBuf;
267
+ }
268
+
269
+ get isClosed(): boolean {
270
+ return this.closed;
271
+ }
272
+
273
+ /** Consumed-outbox byte offset — persist this to survive host restarts. */
274
+ get outboxOffset(): number {
275
+ return this.offset;
276
+ }
277
+
278
+ /**
279
+ * Stop consuming WITHOUT touching the runner — the host-restart path in
280
+ * miniature (a replaced consumer, a shutting-down host). The CLI lives on;
281
+ * a later attach() resumes from the persisted offset.
282
+ */
283
+ detach(): void {
284
+ if (this.detached || this.closed) return;
285
+ this.detached = true;
286
+ if (this.poller) clearInterval(this.poller);
287
+ if (this.closeTimer) clearTimeout(this.closeTimer);
288
+ }
289
+
290
+ /** Real teardown: ask the runner to stop its CLI, then observe the exit. */
291
+ async close(): Promise<void> {
292
+ if (this.closed || this.detached) return;
293
+ try {
294
+ this.onCloseRequested?.();
295
+ } catch {
296
+ // Bookkeeping only — never blocks the teardown itself.
297
+ }
298
+ try {
299
+ await this.inboxChain;
300
+ await fsp.appendFile(this.inboxPath, '{"__uai":"stop"}\n');
301
+ } catch {
302
+ // Inbox unwritable — fall through to the grace timer.
303
+ }
304
+ this.closeTimer = setTimeout(() => this.finish(null), CLOSE_GRACE_MS);
305
+ }
306
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Agent transport selection (ADR-061). Adapters call createAgentTransport()
3
+ * where they used to build a LineProcess directly; it returns either:
4
+ *
5
+ * - legacy pipes (`docker exec -i <cli>`, LineProcess) when
6
+ * UAI_DURABLE_SESSIONS=0, or
7
+ * - the durable path: an in-container runner owning the CLI, driven through
8
+ * inbox/outbox files on the workspace mount (DurableProcess).
9
+ *
10
+ * Durable mode makes host restarts transparent WITHOUT orchestrator
11
+ * changes: the orchestrator's lazy respawn calls factory.create as always,
12
+ * and this helper quietly ATTACHES to a still-live runner (fresh heartbeat +
13
+ * a `running` host_agent_sessions row) at the persisted outbox offset
14
+ * instead of spawning a new CLI — the agent's in-memory context survives.
15
+ * Adapters that re-handshake per process (codex) pass allowAttach:false and
16
+ * get a fresh runner each create — the stale predecessor is asked to stop
17
+ * via its inbox first.
18
+ *
19
+ * The runner script is COPIED into each session dir (not bind-mounted):
20
+ * every existing container already sees the workspace at its identical host
21
+ * path, so durable sessions work for containers created before this
22
+ * feature, and each spawn ships the runner version matching this host.
23
+ */
24
+
25
+ import { copyFileSync, mkdirSync, statSync, promises as fsp } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ import { and, eq } from "drizzle-orm";
29
+
30
+ import { getDb, schema } from "../db";
31
+ import { taskWorkspaceDir } from "../env";
32
+ import { DurableProcess, runnerScriptPath } from "./durable-proc";
33
+ import { LineProcess, dockerExecArgs, type ExitHandler, type LineHandler } from "./proc";
34
+
35
+ /** The shared surface adapters program against (LineProcess's shape). */
36
+ export interface LineTransport {
37
+ onLine(handler: LineHandler): void;
38
+ onExit(handler: ExitHandler): void;
39
+ writeLine(value: unknown): void;
40
+ readonly stderrTail: string;
41
+ readonly isClosed: boolean;
42
+ close(): Promise<void>;
43
+ }
44
+
45
+ export interface AgentTransportOptions {
46
+ taskId: string;
47
+ agentId: string;
48
+ containerName: string;
49
+ /** CLI + args exactly as they'd follow `docker exec -i <container>`. */
50
+ cli: string;
51
+ cliArgs: string[];
52
+ /** Env names forwarded from the host's own env (`-e NAME`). */
53
+ passEnv?: string[];
54
+ /** Explicit per-exec env values (`-e NAME=value`). */
55
+ explicitEnv?: Record<string, string>;
56
+ /**
57
+ * Whether a live runner from a previous host process may be attached to.
58
+ * True for stateless adapters (claude); false for ones whose host-side
59
+ * protocol state can't outlive the host process (codex handshake).
60
+ */
61
+ allowAttach: boolean;
62
+ kind: string;
63
+ debugLabel?: string;
64
+ }
65
+
66
+ /** Runner heartbeat is 5 s; older than this = not attachable. */
67
+ const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
68
+
69
+ function durableEnabled(): boolean {
70
+ return process.env.UAI_DURABLE_SESSIONS !== "0";
71
+ }
72
+
73
+ export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
74
+ if (!durableEnabled()) {
75
+ const { command, args } = dockerExecArgs(
76
+ opts.containerName,
77
+ opts.cli,
78
+ opts.cliArgs,
79
+ opts.passEnv ?? [],
80
+ opts.explicitEnv ?? {},
81
+ );
82
+ return new LineProcess({ command, args, debugLabel: opts.debugLabel });
83
+ }
84
+
85
+ const db = getDb();
86
+ const row = db
87
+ .select()
88
+ .from(schema.hostAgentSessions)
89
+ .where(
90
+ and(
91
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
92
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
93
+ ),
94
+ )
95
+ .get();
96
+
97
+ const persistOffset = (offset: number): void => {
98
+ db.update(schema.hostAgentSessions)
99
+ .set({ outboxOffset: offset, updatedAt: Date.now() })
100
+ .where(
101
+ and(
102
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
103
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
104
+ ),
105
+ )
106
+ .run();
107
+ };
108
+ const markClosed = (): void => {
109
+ db.update(schema.hostAgentSessions)
110
+ .set({ status: "closed", updatedAt: Date.now() })
111
+ .where(
112
+ and(
113
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
114
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
115
+ ),
116
+ )
117
+ .run();
118
+ };
119
+
120
+ // ---- Attach: a previous host process left this agent's runner alive. ----
121
+ if (opts.allowAttach && row && row.status === "running" && heartbeatFresh(row.sessionDir)) {
122
+ const proc = new DurableProcess({
123
+ hostSessionDir: row.sessionDir,
124
+ initialOutboxOffset: row.outboxOffset,
125
+ onOffsetAdvance: persistOffset,
126
+ onCloseRequested: markClosed,
127
+ debugLabel: opts.debugLabel,
128
+ });
129
+ proc.onExit(markClosed);
130
+ return proc;
131
+ }
132
+
133
+ // ---- Spawn a fresh runner, asking any predecessor to stop. --------------
134
+ // Unconditional (not gated on row.status): a runner falsely marked closed
135
+ // by a stale-heartbeat verdict may still be alive, and a stop appended to
136
+ // a dead session's inbox is harmless — this is what guarantees one live
137
+ // CLI per (task, agent).
138
+ if (row) {
139
+ void fsp
140
+ .appendFile(join(row.sessionDir, "inbox.jsonl"), '{"__uai":"stop"}\n')
141
+ .catch(() => {
142
+ // Dir already gone / runner already dead — nothing to stop.
143
+ });
144
+ }
145
+
146
+ const sessionDir = join(
147
+ taskWorkspaceDir(opts.taskId),
148
+ ".uai",
149
+ "sessions",
150
+ opts.agentId,
151
+ String(Date.now()),
152
+ );
153
+ mkdirSync(sessionDir, { recursive: true });
154
+ // Ship this host's runner with the session: works in containers created
155
+ // before this feature (the workspace is mounted at its host path) and
156
+ // never skews against the host version.
157
+ const runnerInSession = join(sessionDir, "runner.mjs");
158
+ copyFileSync(runnerScriptPath(), runnerInSession);
159
+
160
+ const envArgs: string[] = [];
161
+ for (const name of opts.passEnv ?? []) {
162
+ if (process.env[name]) envArgs.push("-e", name);
163
+ }
164
+ for (const [name, value] of Object.entries(opts.explicitEnv ?? {})) {
165
+ envArgs.push("-e", `${name}=${value}`);
166
+ }
167
+
168
+ const proc = new DurableProcess({
169
+ hostSessionDir: sessionDir,
170
+ onOffsetAdvance: persistOffset,
171
+ onCloseRequested: markClosed,
172
+ debugLabel: opts.debugLabel,
173
+ spawnCommand: {
174
+ command: "docker",
175
+ args: [
176
+ "exec",
177
+ "-d",
178
+ ...envArgs,
179
+ opts.containerName,
180
+ "node",
181
+ runnerInSession, // identical path in-container (workspace self-mount)
182
+ sessionDir,
183
+ "--",
184
+ opts.cli,
185
+ ...opts.cliArgs,
186
+ ],
187
+ },
188
+ });
189
+
190
+ const now = Date.now();
191
+ db.insert(schema.hostAgentSessions)
192
+ .values({
193
+ taskId: opts.taskId,
194
+ agentId: opts.agentId,
195
+ sessionDir,
196
+ containerName: opts.containerName,
197
+ kind: opts.kind,
198
+ outboxOffset: 0,
199
+ status: "running",
200
+ createdAt: now,
201
+ updatedAt: now,
202
+ })
203
+ .onConflictDoUpdate({
204
+ target: [schema.hostAgentSessions.taskId, schema.hostAgentSessions.agentId],
205
+ set: {
206
+ sessionDir,
207
+ containerName: opts.containerName,
208
+ kind: opts.kind,
209
+ outboxOffset: 0,
210
+ status: "running",
211
+ updatedAt: now,
212
+ },
213
+ })
214
+ .run();
215
+
216
+ proc.onExit(markClosed);
217
+ return proc;
218
+ }
219
+
220
+ function heartbeatFresh(sessionDir: string): boolean {
221
+ try {
222
+ return (
223
+ Date.now() - statSync(join(sessionDir, "heartbeat")).mtimeMs <
224
+ ATTACH_HEARTBEAT_FRESH_MS
225
+ );
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
@@ -368,31 +368,23 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
368
368
  throw new Error("connection has no pending OAuth flow");
369
369
  }
370
370
 
371
- const body = new URLSearchParams({
372
- grant_type: "authorization_code",
373
- code,
374
- redirect_uri: row.redirectUri,
375
- client_id: row.clientId,
376
- code_verifier: unpack(row.pkceVerifierEnc),
377
- // RFC 8707, mirrored from the authorize request.
378
- resource: row.url,
379
- });
380
- const headers: Record<string, string> = {
381
- "content-type": "application/x-www-form-urlencoded",
382
- accept: "application/json",
383
- };
384
- if (row.clientSecretEnc) {
385
- headers.authorization =
386
- "Basic " +
387
- Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
388
- "base64",
389
- );
390
- }
391
- const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
392
- const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
393
- if (!res.ok || typeof json.access_token !== "string") {
371
+ const { ok, status, json } = await postTokenEndpoint(
372
+ row.tokenEndpoint,
373
+ {
374
+ grant_type: "authorization_code",
375
+ code,
376
+ redirect_uri: row.redirectUri,
377
+ client_id: row.clientId,
378
+ code_verifier: unpack(row.pkceVerifierEnc),
379
+ // RFC 8707, mirrored from the authorize request.
380
+ resource: row.url,
381
+ },
382
+ row.clientId,
383
+ row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
384
+ );
385
+ if (!ok || typeof json.access_token !== "string") {
394
386
  const detail =
395
- typeof json.error === "string" ? json.error : `status ${res.status}`;
387
+ typeof json.error === "string" ? json.error : `status ${status}`;
396
388
  throw new Error(`token exchange failed (${detail})`);
397
389
  }
398
390
 
@@ -473,6 +465,54 @@ export async function authHeaderFor(
473
465
  return { name: "authorization", value: `Bearer ${secret.accessToken}` };
474
466
  }
475
467
 
468
+ /**
469
+ * POST a token-endpoint request. A client secret rides HTTP Basic first
470
+ * (RFC 6749's MUST-support method, what DCR vendors expect); on failure it
471
+ * retries once with `client_secret` in the body (client_secret_post) —
472
+ * some manual-creds vendors (Slack's user-token endpoint) only read body
473
+ * credentials and answer Basic with bad_client_secret.
474
+ */
475
+ async function postTokenEndpoint(
476
+ tokenEndpoint: string,
477
+ params: Record<string, string>,
478
+ clientId: string,
479
+ clientSecret: string | undefined,
480
+ ): Promise<{ ok: boolean; status: number; json: Record<string, unknown> }> {
481
+ const attempt = async (mode: "basic" | "post") => {
482
+ const body = new URLSearchParams(params);
483
+ const headers: Record<string, string> = {
484
+ "content-type": "application/x-www-form-urlencoded",
485
+ accept: "application/json",
486
+ };
487
+ if (clientSecret) {
488
+ if (mode === "basic") {
489
+ headers.authorization =
490
+ "Basic " +
491
+ Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
492
+ } else {
493
+ body.set("client_secret", clientSecret);
494
+ }
495
+ }
496
+ const res = await timedFetch(tokenEndpoint, {
497
+ method: "POST",
498
+ headers,
499
+ body,
500
+ });
501
+ const json = (await res.json().catch(() => ({}))) as Record<
502
+ string,
503
+ unknown
504
+ >;
505
+ return {
506
+ ok: res.ok && typeof json.access_token === "string",
507
+ status: res.status,
508
+ json,
509
+ };
510
+ };
511
+ const basic = await attempt("basic");
512
+ if (basic.ok || !clientSecret) return basic;
513
+ return attempt("post");
514
+ }
515
+
476
516
  async function refreshTokens(
477
517
  row: schema.McpConnection,
478
518
  secret: OauthSecret,
@@ -480,27 +520,19 @@ async function refreshTokens(
480
520
  if (!secret.refreshToken || !row.tokenEndpoint || !row.clientId) {
481
521
  throw new Error("access token expired and no refresh token is stored");
482
522
  }
483
- const body = new URLSearchParams({
484
- grant_type: "refresh_token",
485
- refresh_token: secret.refreshToken,
486
- client_id: row.clientId,
487
- resource: row.url,
488
- });
489
- const headers: Record<string, string> = {
490
- "content-type": "application/x-www-form-urlencoded",
491
- accept: "application/json",
492
- };
493
- if (row.clientSecretEnc) {
494
- headers.authorization =
495
- "Basic " +
496
- Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
497
- "base64",
498
- );
499
- }
500
- const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
501
- const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
502
- if (!res.ok || typeof json.access_token !== "string") {
503
- throw new Error(`token refresh failed (status ${res.status})`);
523
+ const { ok, status, json } = await postTokenEndpoint(
524
+ row.tokenEndpoint,
525
+ {
526
+ grant_type: "refresh_token",
527
+ refresh_token: secret.refreshToken,
528
+ client_id: row.clientId,
529
+ resource: row.url,
530
+ },
531
+ row.clientId,
532
+ row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
533
+ );
534
+ if (!ok || typeof json.access_token !== "string") {
535
+ throw new Error(`token refresh failed (status ${status})`);
504
536
  }
505
537
  const next: OauthSecret = {
506
538
  accessToken: json.access_token,
@@ -19,7 +19,7 @@
19
19
  import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
20
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
21
21
  import { randomBytes, timingSafeEqual } from "node:crypto";
22
- import { Readable } from "node:stream";
22
+ import { Readable, pipeline } from "node:stream";
23
23
  import { resolve } from "node:path";
24
24
 
25
25
  import { env } from "./env";
@@ -31,7 +31,11 @@ const BIND = process.env.UAI_MCP_GATEWAY_BIND ?? "127.0.0.1";
31
31
  /** MCP request bodies are small JSON-RPC frames; cap so audit parsing (and a
32
32
  * hostile container) can't balloon host memory. Responses stream freely. */
33
33
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
34
- const UPSTREAM_TIMEOUT_MS = 120_000;
34
+ /** Bounds the upstream CONNECT (until response headers) only. The response
35
+ * BODY must never be time-bounded: MCP streamable-HTTP clients hold SSE
36
+ * streams open indefinitely, and an abort mid-pipe once took the whole host
37
+ * process down as an unhandled Readable 'error' (2026-07-13). */
38
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 60_000;
35
39
 
36
40
  export interface TaskMcpConnection {
37
41
  id: string;
@@ -181,15 +185,29 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
181
185
  if (typeof v === "string") headers[name] = v;
182
186
  }
183
187
 
188
+ // The controller lives for the whole exchange: the timer only guards the
189
+ // connect phase (cleared once headers land), and a client hang-up reaps the
190
+ // upstream so dead streams don't accumulate.
191
+ const controller = new AbortController();
192
+ res.on("close", () => controller.abort());
193
+
184
194
  const attempt = async (forceRefresh: boolean): Promise<Response> => {
185
195
  const auth = await authHeaderFor(conn, forceRefresh);
186
196
  if (auth) headers[auth.name] = auth.value;
187
- return fetch(conn.url, {
188
- method: req.method,
189
- headers,
190
- body: body ?? undefined,
191
- signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
192
- });
197
+ const connectTimer = setTimeout(
198
+ () => controller.abort(),
199
+ UPSTREAM_CONNECT_TIMEOUT_MS,
200
+ );
201
+ try {
202
+ return await fetch(conn.url, {
203
+ method: req.method,
204
+ headers,
205
+ body: body ?? undefined,
206
+ signal: controller.signal,
207
+ });
208
+ } finally {
209
+ clearTimeout(connectTimer);
210
+ }
193
211
  };
194
212
 
195
213
  let upstream: Response;
@@ -220,8 +238,17 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
220
238
  }
221
239
  res.writeHead(upstream.status, resHeaders);
222
240
  if (upstream.body) {
223
- Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]).pipe(
241
+ // pipeline (NOT .pipe) so a mid-stream error — client gone, upstream
242
+ // reset, abort — tears both ends down instead of crashing the process
243
+ // as an unhandled Readable 'error'.
244
+ pipeline(
245
+ Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]),
224
246
  res,
247
+ (err) => {
248
+ if (err && err.name !== "AbortError") {
249
+ audit({ taskId, slug, stream: "ended", error: err.name });
250
+ }
251
+ },
225
252
  );
226
253
  } else {
227
254
  res.end();
@@ -92,6 +92,10 @@ interface Channel {
92
92
  /** Per-agent respawn counter — bounded so a broken agent can't
93
93
  * loop forever rewriting its config. */
94
94
  respawns: Map<string, number>;
95
+ /** When each agent last burned a respawn — a budget older than the
96
+ * cooldown resets, so a task self-heals once the underlying failure
97
+ * (missing CLI, broken config) clears instead of needing an operator. */
98
+ respawnLastAt: Map<string, number>;
95
99
  /** ADR-049: humans in the chat (from the latest channel spec). */
96
100
  humans: ChannelHuman[];
97
101
  /** ADR-053: wire the Playwright MCP browser at session start. */
@@ -109,6 +113,8 @@ interface Channel {
109
113
 
110
114
  /** Hard cap on automatic respawns per agent per channel lifetime. */
111
115
  const MAX_RESPAWNS_PER_AGENT = 5;
116
+ /** A burned respawn budget resets after this quiet period (see reconcile). */
117
+ const RESPAWN_COOLDOWN_MS = 10 * 60_000;
112
118
 
113
119
  /** Substrings in an agent's error output that mean "config was
114
120
  * unlinked between runs" — repair-and-respawn covers the common
@@ -240,6 +246,7 @@ class Orchestrator {
240
246
  openTurns: new Set(),
241
247
  interrupted: new Set(),
242
248
  respawns: new Map(),
249
+ respawnLastAt: new Map(),
243
250
  humans: spec.humans ?? [],
244
251
  browserTesting: spec.browserTesting === true,
245
252
  mcpConnections: spec.mcpConnections ?? [],
@@ -273,7 +280,14 @@ class Orchestrator {
273
280
  (ok) => {
274
281
  if (!ok) channel.sessionsReady = null;
275
282
  },
276
- () => {
283
+ (err: unknown) => {
284
+ // A start failure retries on the next ensure — but it must be
285
+ // VISIBLE: an unlogged throw here once looped silently every ~2s
286
+ // while a task sat dead with no sessions and no trace.
287
+ console.warn(
288
+ `[orchestrator] ${channel.taskId}: session start failed: ` +
289
+ `${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
290
+ );
277
291
  channel.sessionsReady = null;
278
292
  },
279
293
  );
@@ -306,14 +320,23 @@ class Orchestrator {
306
320
 
307
321
  /** Spawn sessions for roster agents added after the initial start. */
308
322
  private async reconcileSessions(channel: Channel): Promise<void> {
309
- const missing = channel.roster.filter(
310
- (agent) =>
311
- !channel.sessions.has(agent.id) &&
312
- !channel.spawning.has(agent.id) &&
313
- // Crash-loop budget: an agent whose session keeps dying stops being
314
- // respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment).
315
- (channel.respawns.get(agent.id) ?? 0) <= MAX_RESPAWNS_PER_AGENT,
316
- );
323
+ const missing = channel.roster.filter((agent) => {
324
+ if (channel.sessions.has(agent.id) || channel.spawning.has(agent.id)) {
325
+ return false;
326
+ }
327
+ // Crash-loop budget: an agent whose session keeps dying stops being
328
+ // respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment)
329
+ // but a budget that has been cold for RESPAWN_COOLDOWN_MS resets, so
330
+ // the task heals itself once the cause (a missing CLI on the shared
331
+ // volume, a broken config) is fixed, instead of staying dead until a
332
+ // host restart.
333
+ if ((channel.respawns.get(agent.id) ?? 0) > MAX_RESPAWNS_PER_AGENT) {
334
+ const lastAt = channel.respawnLastAt.get(agent.id) ?? 0;
335
+ if (Date.now() - lastAt < RESPAWN_COOLDOWN_MS) return false;
336
+ channel.respawns.set(agent.id, 0);
337
+ }
338
+ return true;
339
+ });
317
340
  if (missing.length === 0) return;
318
341
 
319
342
  const task = getHostTask(channel.taskId);
@@ -613,6 +636,7 @@ class Orchestrator {
613
636
  // after a double SIGKILL). Bounded by the respawn budget, checked in
614
637
  // reconcileSessions.
615
638
  channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
639
+ channel.respawnLastAt.set(agentId, Date.now());
616
640
  channel.sessions.delete(agentId);
617
641
  break;
618
642
  }
@@ -635,6 +659,7 @@ class Orchestrator {
635
659
  // Same zombie hazard as the error path — a session whose process
636
660
  // ended (even cleanly) can never carry another turn.
637
661
  channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
662
+ channel.respawnLastAt.set(agentId, Date.now());
638
663
  channel.sessions.delete(agentId);
639
664
  break;
640
665
  }
@@ -653,6 +678,7 @@ class Orchestrator {
653
678
  ): Promise<void> {
654
679
  const tries = (channel.respawns.get(agentId) ?? 0) + 1;
655
680
  channel.respawns.set(agentId, tries);
681
+ channel.respawnLastAt.set(agentId, Date.now());
656
682
 
657
683
  if (tries > MAX_RESPAWNS_PER_AGENT) {
658
684
  this.emitHost({
@@ -238,6 +238,12 @@ async function upgradeVolumeAgentClis(): Promise<void> {
238
238
  const upgrade = await run("docker", [
239
239
  "run",
240
240
  "--rm",
241
+ // The container NAME is the mutex: overlapping boots (or a crash-looping
242
+ // service) must never race two npm installs on the shared volume — that
243
+ // once left it with no `claude` at all (2026-07-13). Docker rejects the
244
+ // duplicate name; we treat that as "already upgrading, skip".
245
+ "--name",
246
+ "uai-cli-upgrade",
241
247
  "-u",
242
248
  "root",
243
249
  "-w",
@@ -250,6 +256,10 @@ async function upgradeVolumeAgentClis(): Promise<void> {
250
256
  `npm install -g ${pkgs} >/dev/null 2>&1 || { rm -rf ${scopeDirs}; npm install -g ${pkgs} >/dev/null 2>&1; } && asdf reshim nodejs >/dev/null 2>&1; ` +
251
257
  bins.map((b) => `printf '%s ' "$(${b} --version 2>/dev/null | head -1)"`).join("; "),
252
258
  ]);
259
+ if (upgrade.code !== 0 && /already in use/i.test(upgrade.stderr)) {
260
+ console.log("[host-agent] agent CLI upgrade already running — skipped");
261
+ return;
262
+ }
253
263
  if (upgrade.code === 0) {
254
264
  console.log(
255
265
  `[host-agent] agent CLIs current on ${ASDF_DATA_VOLUME}: ${upgrade.stdout.trim()}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -40,6 +40,7 @@
40
40
  "db",
41
41
  "scripts/agent",
42
42
  "scripts/install",
43
+ "runner",
43
44
  "images/standard",
44
45
  "ui",
45
46
  "README.md",
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * uai session runner (ADR-061) — runs INSIDE the task container and owns an
4
+ * agent CLI process, so the CLI's lifetime is decoupled from the host
5
+ * process. The host talks to it only through files on the task-workspace
6
+ * bind mount (no sockets/FIFOs — they don't cross the macOS↔VM boundary):
7
+ *
8
+ * <sessionDir>/inbox.jsonl host appends; runner tails → CLI stdin
9
+ * <sessionDir>/outbox.jsonl CLI stdout lines verbatim + __uai meta lines
10
+ * <sessionDir>/heartbeat rewritten every HEARTBEAT_MS (host checks mtime)
11
+ * <sessionDir>/runner.json pid, protocol, argv, startedAt
12
+ *
13
+ * Meta lines are `{"__uai":"spawn"|"exit", ...}`; the host filters them out
14
+ * before handing lines to the protocol adapters. Control lines the host
15
+ * appends to the inbox use the same shape (`{"__uai":"stop"}`); everything
16
+ * else in the inbox goes to the CLI's stdin untouched.
17
+ *
18
+ * Plain Node ≥18, dependency-free, ESM. Testable outside docker: point it
19
+ * at a tmp dir and any line-oriented fake CLI.
20
+ *
21
+ * Usage: node runner.mjs <sessionDir> -- <command> [args...]
22
+ */
23
+
24
+ import { spawn } from "node:child_process";
25
+ import { appendFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import process from "node:process";
28
+
29
+ const PROTOCOL = 1;
30
+ const POLL_MS = 50;
31
+ const HEARTBEAT_MS = 5_000;
32
+ const STDERR_CAP = 8 * 1024;
33
+ const STOP_GRACE_MS = 5_000;
34
+
35
+ // ---- argv ------------------------------------------------------------------
36
+
37
+ const sep = process.argv.indexOf("--");
38
+ const sessionDir = process.argv[2];
39
+ if (!sessionDir || sep < 0 || sep + 1 >= process.argv.length) {
40
+ process.stderr.write("usage: runner.mjs <sessionDir> -- <command> [args...]\n");
41
+ process.exit(2);
42
+ }
43
+ const command = process.argv[sep + 1];
44
+ const args = process.argv.slice(sep + 2);
45
+
46
+ mkdirSync(sessionDir, { recursive: true });
47
+ const inboxPath = join(sessionDir, "inbox.jsonl");
48
+ const outboxPath = join(sessionDir, "outbox.jsonl");
49
+ const heartbeatPath = join(sessionDir, "heartbeat");
50
+ const runnerJsonPath = join(sessionDir, "runner.json");
51
+
52
+ // Single append point — appendFileSync with O_APPEND keeps lines atomic for
53
+ // the sizes we write; the runner is the outbox's only writer.
54
+ function outbox(line) {
55
+ appendFileSync(outboxPath, line.endsWith("\n") ? line : `${line}\n`);
56
+ }
57
+ function meta(kind, extra = {}) {
58
+ outbox(JSON.stringify({ __uai: kind, ts: Date.now(), ...extra }));
59
+ }
60
+
61
+ // ---- the CLI ---------------------------------------------------------------
62
+
63
+ // The runner is launched through the container's asdf `node` shim, which
64
+ // exports its resolved version (ASDF_NODEJS_VERSION et al.) into our env.
65
+ // Passing that through would pin the CLI's own asdf shim to the WORKSPACE's
66
+ // node version — "No claude executable found for nodejs X" when the agent
67
+ // CLIs are installed under a different one. Strip ASDF_* so the CLI shim
68
+ // resolves exactly as a direct `docker exec <cli>` would.
69
+ const cliEnv = { ...process.env };
70
+ for (const key of Object.keys(cliEnv)) {
71
+ if (key.startsWith("ASDF_")) delete cliEnv[key];
72
+ }
73
+
74
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: cliEnv });
75
+
76
+ writeFileSync(
77
+ runnerJsonPath,
78
+ JSON.stringify({
79
+ protocol: PROTOCOL,
80
+ runnerPid: process.pid,
81
+ cliPid: child.pid ?? null,
82
+ command,
83
+ args,
84
+ startedAt: Date.now(),
85
+ }),
86
+ );
87
+ meta("spawn", { protocol: PROTOCOL, runnerPid: process.pid, cliPid: child.pid ?? null });
88
+
89
+ let stderrTail = "";
90
+ child.stderr?.setEncoding("utf8");
91
+ child.stderr?.on("data", (chunk) => {
92
+ stderrTail = (stderrTail + chunk).slice(-STDERR_CAP);
93
+ });
94
+
95
+ // CLI stdout → outbox, complete lines only (partial line buffered).
96
+ let stdoutBuf = "";
97
+ child.stdout?.setEncoding("utf8");
98
+ child.stdout?.on("data", (chunk) => {
99
+ stdoutBuf += chunk;
100
+ for (;;) {
101
+ const nl = stdoutBuf.indexOf("\n");
102
+ if (nl < 0) break;
103
+ const line = stdoutBuf.slice(0, nl);
104
+ stdoutBuf = stdoutBuf.slice(nl + 1);
105
+ if (line.trim().length > 0) outbox(line);
106
+ }
107
+ });
108
+
109
+ let exiting = false;
110
+ child.on("exit", (code, signal) => {
111
+ if (exiting) return;
112
+ exiting = true;
113
+ if (stdoutBuf.trim().length > 0) outbox(stdoutBuf); // flush the partial tail
114
+ meta("exit", { code, signal: signal ?? null, stderrTail });
115
+ process.exit(0);
116
+ });
117
+ child.on("error", (err) => {
118
+ if (exiting) return;
119
+ exiting = true;
120
+ meta("exit", { code: null, signal: null, stderrTail: String(err?.message ?? err) });
121
+ process.exit(0);
122
+ });
123
+
124
+ // ---- inbox tail → CLI stdin --------------------------------------------------
125
+
126
+ let inboxOffset = 0; // fresh session dir per spawn — always start at 0
127
+ let inboxBuf = "";
128
+
129
+ function stopCli() {
130
+ if (exiting) return;
131
+ try {
132
+ child.kill("SIGTERM");
133
+ } catch {
134
+ /* already gone */
135
+ }
136
+ setTimeout(() => {
137
+ try {
138
+ child.kill("SIGKILL");
139
+ } catch {
140
+ /* already gone */
141
+ }
142
+ }, STOP_GRACE_MS).unref();
143
+ }
144
+
145
+ function handleInboxLine(line) {
146
+ if (line.trim().length === 0) return;
147
+ if (line.startsWith('{"__uai"')) {
148
+ try {
149
+ const ctl = JSON.parse(line);
150
+ if (ctl.__uai === "stop") stopCli();
151
+ } catch {
152
+ /* malformed control — ignore */
153
+ }
154
+ return;
155
+ }
156
+ try {
157
+ child.stdin?.write(`${line}\n`);
158
+ } catch {
159
+ /* EPIPE after CLI death — the exit meta already tells the host */
160
+ }
161
+ }
162
+
163
+ function pollInbox() {
164
+ let fd;
165
+ try {
166
+ fd = openSync(inboxPath, "r");
167
+ } catch {
168
+ return; // inbox not created yet
169
+ }
170
+ try {
171
+ const size = fstatSync(fd).size;
172
+ if (size > inboxOffset) {
173
+ const len = size - inboxOffset;
174
+ const buf = Buffer.alloc(len);
175
+ const read = readSync(fd, buf, 0, len, inboxOffset);
176
+ inboxOffset += read;
177
+ inboxBuf += buf.toString("utf8", 0, read);
178
+ for (;;) {
179
+ const nl = inboxBuf.indexOf("\n");
180
+ if (nl < 0) break;
181
+ const line = inboxBuf.slice(0, nl);
182
+ inboxBuf = inboxBuf.slice(nl + 1);
183
+ handleInboxLine(line);
184
+ }
185
+ }
186
+ } finally {
187
+ closeSync(fd);
188
+ }
189
+ }
190
+
191
+ setInterval(pollInbox, POLL_MS);
192
+
193
+ // ---- heartbeat ---------------------------------------------------------------
194
+
195
+ function beat() {
196
+ try {
197
+ writeFileSync(heartbeatPath, `${process.pid} ${Date.now()}\n`);
198
+ } catch {
199
+ /* disk hiccup — next beat retries */
200
+ }
201
+ }
202
+ beat();
203
+ setInterval(beat, HEARTBEAT_MS);
204
+
205
+ // The runner dies only with its CLI (or on stop/SIGTERM) — never because a
206
+ // host-side consumer went away. That is the entire point (ADR-061).
207
+ process.on("SIGTERM", stopCli);
208
+ process.on("SIGINT", stopCli);