@runuai/host 0.4.2 → 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,18 @@
1
+ -- ADR-057: user-authed MCP connections. Secrets sealed with the host master
2
+ -- key (ct.nonce base64, like host_project_env.value_enc); the cloud holds only
3
+ -- non-secret metadata.
4
+ CREATE TABLE `host_mcp_connections` (
5
+ `id` text PRIMARY KEY NOT NULL,
6
+ `user_id` text NOT NULL,
7
+ `url` text NOT NULL,
8
+ `auth_kind` text NOT NULL,
9
+ `token_endpoint` text,
10
+ `redirect_uri` text,
11
+ `client_id` text,
12
+ `client_secret_enc` text,
13
+ `pkce_verifier_enc` text,
14
+ `secret_enc` text,
15
+ `status` text DEFAULT 'pending' NOT NULL,
16
+ `scopes` text,
17
+ `updated_at` integer NOT NULL
18
+ );
@@ -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
+ );
@@ -57,6 +57,20 @@
57
57
  "when": 1779900008000,
58
58
  "tag": "0007_host_github_token_kind",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1779900009000,
65
+ "tag": "0008_host_mcp_connections",
66
+ "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1779900010000,
72
+ "tag": "0009_host_agent_sessions",
73
+ "breakpoints": true
60
74
  }
61
75
  ]
62
- }
76
+ }
package/db/schema.ts CHANGED
@@ -115,3 +115,64 @@ export const hostProjectEnv = sqliteTable(
115
115
 
116
116
  export type HostProjectEnv = typeof hostProjectEnv.$inferSelect;
117
117
  export type NewHostProjectEnv = typeof hostProjectEnv.$inferInsert;
118
+
119
+ // User-authed MCP connections (ADR-057). One row per cloud connection id; all
120
+ // secret material (static header, OAuth client secret, PKCE verifier, tokens)
121
+ // is sealed with the host master key in single-column `ct.nonce` base64 form
122
+ // (same packing as host_project_env). The cloud only ever holds the non-secret
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
+
157
+ export const mcpConnections = sqliteTable("host_mcp_connections", {
158
+ id: text("id").primaryKey(), // cloud uai_mcp_connections id
159
+ userId: text("user_id").notNull(),
160
+ url: text("url").notNull(),
161
+ // "oauth" | "token" | "none"
162
+ authKind: text("auth_kind").notNull(),
163
+ // OAuth machinery discovered at probe time.
164
+ tokenEndpoint: text("token_endpoint"),
165
+ redirectUri: text("redirect_uri"),
166
+ clientId: text("client_id"),
167
+ clientSecretEnc: text("client_secret_enc"),
168
+ // PKCE verifier held between probe and oauth.complete.
169
+ pkceVerifierEnc: text("pkce_verifier_enc"),
170
+ // Sealed JSON: {"headerName","headerValue"} (token kind) or
171
+ // {"accessToken","refreshToken","expiresAt"} (oauth kind). Null for "none".
172
+ secretEnc: text("secret_enc"),
173
+ status: text("status").notNull().default("pending"), // pending|connected
174
+ scopes: text("scopes"),
175
+ updatedAt: integer("updated_at", { mode: "number" }).notNull(),
176
+ });
177
+
178
+ export type McpConnection = typeof mcpConnections.$inferSelect;
@@ -70,6 +70,21 @@ RUN apt-get update \
70
70
  zsh \
71
71
  && rm -rf /var/lib/apt/lists/*
72
72
 
73
+ # ADR-053: agent browser (Playwright/Chromium) runtime libs + the watchable
74
+ # display stack (Xvfb → x11vnc → noVNC). Baked here because the
75
+ # per-container background apt raced session start (headless-stuck MCP
76
+ # server, viewer lagging the preview by minutes — Debian's novnc drags a
77
+ # large Python tree). The Chromium BINARY still comes from the shared
78
+ # uai-playwright volume (downloaded once per host); these are its deps.
79
+ RUN apt-get update \
80
+ && apt-get install -y --no-install-recommends \
81
+ libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
82
+ libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 \
83
+ libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
84
+ fonts-liberation fonts-unifont \
85
+ xvfb x11vnc novnc websockify \
86
+ && rm -rf /var/lib/apt/lists/*
87
+
73
88
  # GitHub CLI (`gh`) — used by the ship/PR flow inside the container.
74
89
  RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
75
90
  | tee /usr/share/keyrings/githubcli-archive-keyring.gpg >/dev/null \
package/lib/agent-cli.ts CHANGED
@@ -212,6 +212,11 @@ async function main() {
212
212
  break;
213
213
  }
214
214
  case "whoami ": case "whoami undefined": out({ apiUrl: API_URL }); break;
215
+ case "react heart": case "react check": case "react x": {
216
+ const r = await api("POST", "/api/agent/react", { emoji: action, msg: flags.msg || undefined });
217
+ out((r.reacted ? "reacted to " : "un-reacted from ") + r.messageId);
218
+ break;
219
+ }
215
220
  default:
216
221
  console.error([
217
222
  "uai — agent CLI. Commands:",
@@ -222,6 +227,7 @@ async function main() {
222
227
  " uai memory search <query>",
223
228
  " uai memory save <text> [--project id] [--tags a,b]",
224
229
  " uai memory delete <id>",
230
+ " uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
225
231
  " uai whoami",
226
232
  ].join("\\n"));
227
233
  process.exit(argv.length ? 1 : 0);
@@ -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,
@@ -35,10 +35,11 @@ import type {
35
35
  // resolves these aliases to the current dated snapshots, so this list is
36
36
  // stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
37
37
  // retired alias). Order is display order in the cloud picker.
38
- const CLAUDE_MODELS = ["opus", "sonnet", "haiku"];
38
+ const CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
39
39
 
40
- // Opus ("opus" alias = Opus 4.8) is the default: the strongest coding model
41
- // for the interactive loop. Update alongside CLAUDE_MODELS.
40
+ // Opus ("opus" alias = Opus 4.8) stays the default; "fable" (Claude
41
+ // Fable 5, the Mythos-class flagship) is opt-in per agent. Update
42
+ // alongside CLAUDE_MODELS.
42
43
  const CLAUDE_DEFAULT_MODEL = "opus";
43
44
 
44
45
  // Reasoning levels passed through via `claude --effort <level>`. Taken from
@@ -192,11 +193,12 @@ export class ClaudeSession implements AgentSession {
192
193
  readonly agentId: string;
193
194
  readonly kind: AgentKind = "claude";
194
195
 
195
- private readonly proc: LineProcess;
196
+ private readonly proc: LineTransport;
196
197
  private readonly handlers = new Set<AgentEventHandler>();
197
198
  private closed = false;
198
199
 
199
200
  constructor(args: {
201
+ taskId: string;
200
202
  agent: RosterAgent;
201
203
  containerName: string;
202
204
  systemPreamble: string;
@@ -230,16 +232,20 @@ export class ClaudeSession implements AgentSession {
230
232
  // it needs CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or an
231
233
  // API key. These live in the host-agent's env (never the cloud, ADR-015);
232
234
  // only ones actually set are forwarded.
233
- const { command, args: argv } = dockerExecArgs(
234
- args.containerName,
235
- "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",
236
244
  cliArgs,
237
- ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
238
- args.agentEnv ?? {},
239
- );
240
- this.proc = new LineProcess({
241
- command,
242
- args: argv,
245
+ passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
246
+ explicitEnv: args.agentEnv ?? {},
247
+ allowAttach: true,
248
+ kind: "claude",
243
249
  debugLabel: `claude:${this.agentId}`,
244
250
  });
245
251
  this.proc.onLine((line) => {
@@ -348,6 +354,6 @@ register({
348
354
  process.env.ANTHROPIC_API_KEY ||
349
355
  process.env.ANTHROPIC_AUTH_TOKEN,
350
356
  ),
351
- create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
352
- new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
357
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
358
+ new ClaudeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
353
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,
@@ -49,6 +49,9 @@ import type {
49
49
  // Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
50
50
  // in the cloud picker. Legacy models remain reachable via config.toml.
51
51
  const CODEX_MODELS = [
52
+ "gpt-5.6-sol",
53
+ "gpt-5.6-terra",
54
+ "gpt-5.6-luna",
52
55
  "gpt-5.5",
53
56
  "gpt-5.4",
54
57
  "gpt-5.4-mini",
@@ -57,16 +60,20 @@ const CODEX_MODELS = [
57
60
  "gpt-5.2",
58
61
  ];
59
62
 
60
- // gpt-5.5 is Codex's current frontier coding model. Update alongside
61
- // CODEX_MODELS. When an agent's model is null the adapter omits the override
62
- // entirely and Codex uses the user's own configured default.
63
- const CODEX_DEFAULT_MODEL = "gpt-5.5";
63
+ // gpt-5.6-sol is Codex's current frontier coding model (the CLI default).
64
+ // Update alongside CODEX_MODELS. When an agent's model is null the adapter
65
+ // omits the override entirely and Codex uses the user's own configured
66
+ // default.
67
+ const CODEX_DEFAULT_MODEL = "gpt-5.6-sol";
64
68
 
65
- // Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is the
66
- // picker's "Extra high". UPDATE WHEN CODEX CHANGES its reasoning levels.
67
- const CODEX_EFFORTS = ["low", "medium", "high", "xhigh"];
69
+ // Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is
70
+ // the picker's "Extra high"; gpt-5.6 added "max" and "ultra" (ultra =
71
+ // maximum reasoning with automatic task delegation). UPDATE WHEN CODEX
72
+ // CHANGES its reasoning levels.
73
+ const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
68
74
 
69
- // medium is Codex's default reasoning level. Update alongside CODEX_EFFORTS.
75
+ // medium stays OUR default (the 5.6 CLI defaults to low — too light for
76
+ // agentic task work). Update alongside CODEX_EFFORTS.
70
77
  const CODEX_DEFAULT_EFFORT = "medium";
71
78
 
72
79
  // ---------------------------------------------------------------------------
@@ -206,7 +213,7 @@ export class CodexSession implements AgentSession {
206
213
  readonly agentId: string;
207
214
  readonly kind: AgentKind = "codex";
208
215
 
209
- private readonly proc: LineProcess;
216
+ private readonly proc: LineTransport;
210
217
  private readonly handlers = new Set<AgentEventHandler>();
211
218
  private readonly systemPreamble: string;
212
219
  private closed = false;
@@ -224,6 +231,7 @@ export class CodexSession implements AgentSession {
224
231
  private readonly ready: Promise<void>;
225
232
 
226
233
  constructor(args: {
234
+ taskId: string;
227
235
  agent: RosterAgent;
228
236
  containerName: string;
229
237
  systemPreamble: string;
@@ -244,16 +252,22 @@ export class CodexSession implements AgentSession {
244
252
  codexArgs.push("-c", `model_reasoning_effort=${args.agent.effort}`);
245
253
  }
246
254
  codexArgs.push("app-server");
247
- const { command, args: argv } = dockerExecArgs(
248
- args.containerName,
249
- "codex",
250
- codexArgs,
251
- [],
252
- args.agentEnv ?? {},
253
- );
254
- this.proc = new LineProcess({
255
- command,
256
- 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",
257
271
  debugLabel: `codex:${this.agentId}`,
258
272
  });
259
273
  this.proc.onLine((line) => this.onLine(line));
@@ -531,6 +545,6 @@ register({
531
545
  existsSync(
532
546
  join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
533
547
  ),
534
- create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
535
- new CodexSession({ agent, containerName, systemPreamble, agentEnv }),
548
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
549
+ new CodexSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
536
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
+ }