@runuai/host 0.8.35 → 0.8.37

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,13 @@
1
+ -- ADR-076: multiple accounts per engine. One row per EXTRA (non-default)
2
+ -- credential for an engine kind; the legacy single slot is synthesized as the
3
+ -- "default" account and is NOT stored here. Secret material is sealed with the
4
+ -- host master key (env kind) or lives in an isolated host dir (config-dir kind).
5
+ CREATE TABLE `host_engine_accounts` (
6
+ `id` text PRIMARY KEY NOT NULL,
7
+ `kind` text NOT NULL,
8
+ `label` text NOT NULL,
9
+ `auth_kind` text NOT NULL,
10
+ `secret_enc` text,
11
+ `config_dir` text,
12
+ `created_at` integer NOT NULL
13
+ );
@@ -71,6 +71,13 @@
71
71
  "when": 1779900010000,
72
72
  "tag": "0009_host_agent_sessions",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "6",
78
+ "when": 1779900011000,
79
+ "tag": "0010_host_engine_accounts",
80
+ "breakpoints": true
74
81
  }
75
82
  ]
76
83
  }
package/db/schema.ts CHANGED
@@ -176,3 +176,27 @@ export const mcpConnections = sqliteTable("host_mcp_connections", {
176
176
  });
177
177
 
178
178
  export type McpConnection = typeof mcpConnections.$inferSelect;
179
+
180
+ // Extra (non-default) engine accounts (ADR-076). One row per additional
181
+ // credential for an engine kind, beyond the single legacy slot (.env.local /
182
+ // ~/.codex / ~/.local/share/opencode) which is synthesized as the "default"
183
+ // account and NOT stored here. Secret material is sealed with the host master
184
+ // key (same secret-blind pattern as host_github_tokens — ADR-015):
185
+ // - authKind "env": secretEnc = sealed JSON of env vars, e.g.
186
+ // {"CLAUDE_CODE_OAUTH_TOKEN":"sk-ant-oat…"}.
187
+ // - authKind "config-dir": configDir = the host dir holding this account's
188
+ // auth files (an isolated ~/.codex-acct-<id> etc.);
189
+ // secretEnc is null (the dir IS the secret at rest).
190
+ // Rotation state (cooldown / last-used) is in-memory only, not persisted.
191
+ export const engineAccounts = sqliteTable("host_engine_accounts", {
192
+ id: text("id").primaryKey(), // ulid
193
+ kind: text("kind").notNull(), // "claude" | "codex" | "opencode" | …
194
+ label: text("label").notNull(), // display, e.g. "work"
195
+ authKind: text("auth_kind").notNull(), // "env" | "config-dir"
196
+ secretEnc: text("secret_enc"), // sealed JSON (env kind); null for config-dir
197
+ configDir: text("config_dir"), // host dir (config-dir kind); null for env
198
+ createdAt: integer("created_at", { mode: "number" }).notNull(),
199
+ });
200
+
201
+ export type EngineAccountRow = typeof engineAccounts.$inferSelect;
202
+ export type NewEngineAccountRow = typeof engineAccounts.$inferInsert;
@@ -246,6 +246,15 @@ RUN if [ "$INSTALL_CURSOR" = "1" ]; then \
246
246
  || echo "[warn] cursor install failed — cursor engine unavailable in this image"; \
247
247
  fi
248
248
 
249
+ # OpenCode (ADR-077) — multi-provider CLI. Installs to /home/node/.opencode/bin;
250
+ # the adapter invokes it by absolute path and task-up copies the provider auth
251
+ # (~/.local/share/opencode/auth.json) in. Non-fatal like the others.
252
+ ARG INSTALL_OPENCODE=0
253
+ RUN if [ "$INSTALL_OPENCODE" = "1" ]; then \
254
+ bash -lc 'curl -fsSL https://opencode.ai/install | bash' \
255
+ || echo "[warn] opencode install failed — opencode engine unavailable in this image"; \
256
+ fi
257
+
249
258
  ENV PATH=/home/node/.local/bin:$PATH
250
259
 
251
260
  # ---------------------------------------------------------------------------
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { newId } from "../ulid";
24
24
  import { createAgentTransport, type LineTransport } from "./transport";
25
+ import { isRateLimitMessage } from "./rate-limit";
25
26
  import { register } from "./registry";
26
27
  import { extractResultUsage } from "./usage";
27
28
  import type {
@@ -123,8 +124,13 @@ export function mapClaudeLine(raw: string): AgentEvent[] {
123
124
  const usage = extractResultUsage(json);
124
125
  if (json.is_error === true) {
125
126
  // An errored turn still cost tokens — meter it.
127
+ const message = text || "claude returned an error";
126
128
  return [
127
- { type: "error", message: text || "claude returned an error" },
129
+ // ADR-076: a rate-limit/usage-cap error is failed over to another
130
+ // account by the orchestrator rather than surfaced to the user.
131
+ isRateLimitMessage(message)
132
+ ? { type: "error", message, retryable: "rate_limit" }
133
+ : { type: "error", message },
128
134
  { type: "turn_complete", usage },
129
135
  ];
130
136
  }
@@ -34,6 +34,7 @@ import { join } from "node:path";
34
34
 
35
35
  import { newId } from "../ulid";
36
36
  import { createAgentTransport, type LineTransport } from "./transport";
37
+ import { isRateLimitMessage } from "./rate-limit";
37
38
  import { register } from "./registry";
38
39
  import type {
39
40
  AgentEvent,
@@ -134,8 +135,11 @@ export function mapCodexNotification(
134
135
  if (method === "turn/completed") {
135
136
  const turn = isObj(p.turn) ? p.turn : {};
136
137
  if (turn.status === "failed" && isObj(turn.error)) {
138
+ const message = str(turn.error.message, "codex turn failed");
137
139
  return [
138
- { type: "error", message: str(turn.error.message, "codex turn failed") },
140
+ isRateLimitMessage(message)
141
+ ? { type: "error", message, retryable: "rate_limit" }
142
+ : { type: "error", message },
139
143
  { type: "turn_complete" },
140
144
  ];
141
145
  }
@@ -146,7 +150,9 @@ export function mapCodexNotification(
146
150
  const message = isObj(p.error)
147
151
  ? str(p.error.message, "codex error")
148
152
  : "codex error";
149
- return [{ type: "error", message }];
153
+ return isRateLimitMessage(message)
154
+ ? [{ type: "error", message, retryable: "rate_limit" }]
155
+ : [{ type: "error", message }];
150
156
  }
151
157
 
152
158
  return [];
@@ -20,6 +20,7 @@ import "./codex";
20
20
  import "./kimi";
21
21
  import "./grok";
22
22
  import "./cursor";
23
+ import "./opencode";
23
24
 
24
25
  import { agentClisReady } from "../standard-image";
25
26
  import { factoryFor } from "./registry";
@@ -0,0 +1,461 @@
1
+ /**
2
+ * OpencodeSession — a real AgentSession backed by the OpenCode CLI's
3
+ * non-interactive `run` mode, executed inside the task container (ADR-077):
4
+ *
5
+ * docker exec -i task-<id>-app-1 ~/.opencode/bin/opencode run \
6
+ * --format json --auto --model <provider/model> [--variant <effort>] \
7
+ * [--session <id>] "<prompt>"
8
+ *
9
+ * Like Kimi/Grok/Cursor (ADR-066/067/068) OpenCode is **one-shot per turn**:
10
+ * `run` executes a single prompt to completion, streams newline-delimited JSON
11
+ * events to stdout, and exits. Multi-turn continuity is `--session <id>` (the
12
+ * id is captured from the first turn's event stream). Each `send()` spawns a
13
+ * fresh `docker exec`; sends are serialized so a second message can't race a
14
+ * running turn.
15
+ *
16
+ * `--format json` vocabulary (one object per line, `type` field):
17
+ * {"type":"step_start", ...} → (ignored)
18
+ * {"type":"text","part":{"text":"…"}} → message_complete
19
+ * {"type":"tool_use","part":{"tool","state":{…}}} → tool_call
20
+ * {"type":"step_finish","part":{"cost","tokens":…}} → (accumulated usage)
21
+ * {"type":"error","error":{…}} → error
22
+ * Every event carries a top-level `sessionID` (captured for --session). The
23
+ * turn ends when the process exits (turn_complete, with accumulated usage).
24
+ *
25
+ * AUTH is the operator's OpenCode provider keys in
26
+ * `~/.local/share/opencode/auth.json`, docker-cp'd into the container at
27
+ * task-up (like Codex's ~/.codex) — never the cloud (ADR-015). `--auto`
28
+ * auto-approves tool permissions (the container is the isolation boundary), so
29
+ * there are no permission prompts to resolve. ADR-076: `OPENCODE_DATA_DIR` in
30
+ * agentEnv selects which account's data dir (auth) this exec uses.
31
+ */
32
+ import { spawn, type ChildProcess } from "node:child_process";
33
+ import { existsSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { join } from "node:path";
36
+
37
+ import { newId } from "../ulid";
38
+ import { isRateLimitMessage } from "./rate-limit";
39
+ import { register } from "./registry";
40
+ import type {
41
+ AgentEvent,
42
+ AgentEventHandler,
43
+ AgentKind,
44
+ AgentSession,
45
+ AgentUsage,
46
+ RosterAgent,
47
+ } from "./types";
48
+
49
+ /** Installed by the image's opencode installer (opencode.ai/install); absolute
50
+ * so a non-login `docker exec` doesn't depend on PATH. */
51
+ const OPENCODE_BIN = "/home/node/.opencode/bin/opencode";
52
+
53
+ // OpenCode is multi-provider: models are `provider/model` strings. This is a
54
+ // curated slice keyed to OpenCode Zen (the `opencode/…` gateway that the
55
+ // connect picker defaults to — OpenCode's own coding-benchmarked catalog);
56
+ // any provider/model the operator's auth.json can drive is still typable in
57
+ // the composer (unlisted values round-trip). If you bring native provider
58
+ // keys instead of Zen, type e.g. `anthropic/claude-sonnet-5` directly.
59
+ // UPDATE alongside lib/agent-catalog.ts. Order = display order. Deprecated Zen
60
+ // models are intentionally omitted (they 4xx): the gpt-5.x-codex/gpt-5-codex
61
+ // line (retired 2026-07-23), glm-5, minimax-m2.5, kimi-k2.5.
62
+ const OPENCODE_MODELS = [
63
+ // Anthropic (Claude).
64
+ "opencode/claude-sonnet-5",
65
+ "opencode/claude-opus-5",
66
+ "opencode/claude-opus-4-8",
67
+ "opencode/claude-opus-4-7",
68
+ "opencode/claude-opus-4-6",
69
+ "opencode/claude-opus-4-5",
70
+ "opencode/claude-sonnet-4-6",
71
+ "opencode/claude-sonnet-4-5",
72
+ "opencode/claude-haiku-4-5",
73
+ "opencode/claude-fable-5",
74
+ // OpenAI (GPT).
75
+ "opencode/gpt-5.6-sol",
76
+ "opencode/gpt-5.6-terra",
77
+ "opencode/gpt-5.6-luna",
78
+ "opencode/gpt-5.5",
79
+ "opencode/gpt-5.5-pro",
80
+ "opencode/gpt-5.4",
81
+ "opencode/gpt-5.4-pro",
82
+ "opencode/gpt-5.4-mini",
83
+ "opencode/gpt-5.4-nano",
84
+ "opencode/gpt-5.3-codex",
85
+ "opencode/gpt-5.3-codex-spark",
86
+ "opencode/gpt-5.2",
87
+ "opencode/gpt-5.1",
88
+ "opencode/gpt-5",
89
+ "opencode/gpt-5-nano",
90
+ // Google (Gemini).
91
+ "opencode/gemini-3.6-flash",
92
+ "opencode/gemini-3.5-flash",
93
+ "opencode/gemini-3.5-flash-lite",
94
+ "opencode/gemini-3.1-pro",
95
+ "opencode/gemini-3-flash",
96
+ // xAI (Grok).
97
+ "opencode/grok-4.5",
98
+ "opencode/grok-build-0.1",
99
+ // Open-weight — Moonshot Kimi.
100
+ "opencode/kimi-k2.7-code",
101
+ "opencode/kimi-k2.6",
102
+ // Open-weight — Alibaba Qwen.
103
+ "opencode/qwen3.7-max",
104
+ "opencode/qwen3.7-plus",
105
+ "opencode/qwen3.6-plus",
106
+ "opencode/qwen3.5-plus",
107
+ // Open-weight — DeepSeek.
108
+ "opencode/deepseek-v4-pro",
109
+ "opencode/deepseek-v4-flash",
110
+ // Open-weight — Z.ai GLM.
111
+ "opencode/glm-5.2",
112
+ "opencode/glm-5.1",
113
+ // Open-weight — MiniMax.
114
+ "opencode/minimax-m3",
115
+ "opencode/minimax-m2.7",
116
+ // Free / stealth (time-limited; some retain data during their free window).
117
+ "opencode/big-pickle",
118
+ "opencode/deepseek-v4-flash-free",
119
+ "opencode/mimo-v2.5-free",
120
+ "opencode/laguna-s-2.1-free",
121
+ "opencode/ling-3.0-flash-free",
122
+ "opencode/north-mini-code-free",
123
+ "opencode/nemotron-3-ultra-free",
124
+ ];
125
+ const OPENCODE_DEFAULT_MODEL = "opencode/claude-sonnet-5";
126
+
127
+ // Reasoning effort is a provider-specific `--variant`, not a fixed list, so the
128
+ // picker offers none; a typed effort is still passed through.
129
+ const OPENCODE_EFFORTS: string[] = [];
130
+
131
+ /** Host path to the operator's OpenCode auth — its presence gates the engine
132
+ * (mirrors Codex's ~/.codex/auth.json check). */
133
+ function opencodeAuthPath(): string {
134
+ return join(
135
+ process.env.UAI_OWNER_HOME?.trim() || homedir(),
136
+ ".local",
137
+ "share",
138
+ "opencode",
139
+ "auth.json",
140
+ );
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Pure protocol mapping — one JSON line → AgentEvent[] (+ session id / usage).
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function isObj(v: unknown): v is Record<string, unknown> {
148
+ return typeof v === "object" && v !== null;
149
+ }
150
+
151
+ function str(v: unknown, fallback = ""): string {
152
+ return typeof v === "string" ? v : fallback;
153
+ }
154
+
155
+ function num(v: unknown): number | undefined {
156
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
157
+ }
158
+
159
+ export interface MappedOpencodeLine {
160
+ events: AgentEvent[];
161
+ /** The session id, when this line carried one (for --session continuity). */
162
+ sessionId?: string;
163
+ /** Usage accounting, when this line was a step_finish. */
164
+ usage?: AgentUsage;
165
+ }
166
+
167
+ /** Best-effort one-line detail for a tool card from a tool_use part. */
168
+ function toolDetail(state: unknown): string {
169
+ if (!isObj(state)) return "";
170
+ const title = state.title;
171
+ if (typeof title === "string" && title.length > 0) return title;
172
+ const input = state.input;
173
+ if (typeof input === "string") return input;
174
+ try {
175
+ return input !== undefined ? JSON.stringify(input) : "";
176
+ } catch {
177
+ return "";
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Map a single OpenCode `run --format json` line. Non-JSON lines and events
183
+ * with no user-facing meaning (step_start) map to no events. Total + pure.
184
+ */
185
+ export function mapOpencodeLine(line: string): MappedOpencodeLine {
186
+ const trimmed = line.trim();
187
+ if (!trimmed.startsWith("{")) return { events: [] };
188
+ let msg: Record<string, unknown>;
189
+ try {
190
+ const parsed: unknown = JSON.parse(trimmed);
191
+ if (!isObj(parsed)) return { events: [] };
192
+ msg = parsed;
193
+ } catch {
194
+ return { events: [] };
195
+ }
196
+
197
+ const sessionId =
198
+ typeof msg.sessionID === "string" ? msg.sessionID : undefined;
199
+ const type = str(msg.type);
200
+ const part = isObj(msg.part) ? msg.part : {};
201
+
202
+ if (type === "text") {
203
+ const text = str(part.text);
204
+ return text
205
+ ? { events: [{ type: "message_complete", text }], sessionId }
206
+ : { events: [], sessionId };
207
+ }
208
+
209
+ if (type === "tool_use") {
210
+ const state = isObj(part.state) ? part.state : {};
211
+ return {
212
+ events: [
213
+ {
214
+ type: "tool_call",
215
+ id: str(part.callID) || str(part.id) || newId(),
216
+ title: str(part.tool, "tool"),
217
+ detail: toolDetail(state),
218
+ },
219
+ ],
220
+ sessionId,
221
+ };
222
+ }
223
+
224
+ if (type === "step_finish") {
225
+ const tokens = isObj(part.tokens) ? part.tokens : {};
226
+ const cache = isObj(tokens.cache) ? tokens.cache : {};
227
+ const usage: AgentUsage = {
228
+ inputTokens: num(tokens.input),
229
+ outputTokens: num(tokens.output),
230
+ cacheReadTokens: num(cache.read),
231
+ cacheCreateTokens: num(cache.write),
232
+ costUsd: num(part.cost),
233
+ };
234
+ return { events: [], sessionId, usage };
235
+ }
236
+
237
+ if (type === "error") {
238
+ const err = isObj(msg.error) ? msg.error : {};
239
+ const data = isObj(err.data) ? err.data : {};
240
+ const message =
241
+ str(data.message) || str(err.name) || "opencode error";
242
+ return {
243
+ events: [
244
+ isRateLimitMessage(message)
245
+ ? { type: "error", message, retryable: "rate_limit" }
246
+ : { type: "error", message },
247
+ ],
248
+ sessionId,
249
+ };
250
+ }
251
+
252
+ return { events: [], sessionId };
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Session
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /** Injectable spawn seam (tests provide a fake). */
260
+ export type Spawner = (args: string[]) => ChildProcess;
261
+ const defaultSpawn: Spawner = (args) =>
262
+ spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
263
+
264
+ /** Sum this turn's step usage into a running total. */
265
+ function addUsage(acc: AgentUsage, u: AgentUsage): AgentUsage {
266
+ const add = (a?: number, b?: number): number | undefined =>
267
+ a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
268
+ return {
269
+ inputTokens: add(acc.inputTokens, u.inputTokens),
270
+ outputTokens: add(acc.outputTokens, u.outputTokens),
271
+ cacheReadTokens: add(acc.cacheReadTokens, u.cacheReadTokens),
272
+ cacheCreateTokens: add(acc.cacheCreateTokens, u.cacheCreateTokens),
273
+ costUsd: add(acc.costUsd, u.costUsd),
274
+ };
275
+ }
276
+
277
+ export class OpencodeSession implements AgentSession {
278
+ readonly agentId: string;
279
+ readonly kind: AgentKind = "opencode";
280
+
281
+ private readonly containerName: string;
282
+ private readonly model?: string;
283
+ private readonly effort?: string;
284
+ private readonly systemPreamble: string;
285
+ private readonly agentEnv: Record<string, string>;
286
+ private readonly spawner: Spawner;
287
+
288
+ private readonly handlers = new Set<AgentEventHandler>();
289
+ private sessionId: string | null = null;
290
+ private current: ChildProcess | null = null;
291
+ private queue: Promise<void> = Promise.resolve();
292
+ private sentPreamble = false;
293
+ private closed = false;
294
+
295
+ constructor(args: {
296
+ taskId: string;
297
+ agent: RosterAgent;
298
+ containerName: string;
299
+ systemPreamble: string;
300
+ agentEnv?: Record<string, string>;
301
+ spawner?: Spawner;
302
+ }) {
303
+ this.agentId = args.agent.id;
304
+ this.containerName = args.containerName;
305
+ this.model = args.agent.model;
306
+ this.effort = args.agent.effort;
307
+ this.systemPreamble = args.systemPreamble;
308
+ this.agentEnv = args.agentEnv ?? {};
309
+ this.spawner = args.spawner ?? defaultSpawn;
310
+ }
311
+
312
+ onEvent(handler: AgentEventHandler): () => void {
313
+ this.handlers.add(handler);
314
+ return () => this.handlers.delete(handler);
315
+ }
316
+
317
+ private emit(event: AgentEvent): void {
318
+ if (this.closed && event.type !== "exit") return;
319
+ for (const h of this.handlers) h(event);
320
+ }
321
+
322
+ /** One-shot per turn; serialize so a second message queues behind the
323
+ * running one instead of spawning a racing `opencode run`. */
324
+ async send(text: string): Promise<void> {
325
+ if (this.closed) return;
326
+ this.queue = this.queue.then(() => this.runTurn(text));
327
+ return this.queue;
328
+ }
329
+
330
+ private buildArgs(prompt: string): string[] {
331
+ const args = ["exec", "-i", "-u", "node"];
332
+ for (const [k, v] of Object.entries(this.agentEnv)) {
333
+ args.push("-e", `${k}=${v}`);
334
+ }
335
+ args.push(
336
+ this.containerName,
337
+ OPENCODE_BIN,
338
+ "run",
339
+ "--format",
340
+ "json",
341
+ // Auto-approve permissions not explicitly denied — the container is the
342
+ // sandbox, and headless has no interactive approver.
343
+ "--auto",
344
+ );
345
+ if (this.model) args.push("--model", this.model);
346
+ if (this.effort) args.push("--variant", this.effort);
347
+ // Continue the same OpenCode session across turns (captured from the first
348
+ // turn's stream). First turn has none → a fresh session.
349
+ if (this.sessionId) args.push("--session", this.sessionId);
350
+ // The prompt is positional; put it LAST so it's never mistaken for a flag.
351
+ args.push(prompt);
352
+ return args;
353
+ }
354
+
355
+ private async runTurn(text: string): Promise<void> {
356
+ if (this.closed) return;
357
+ // `run` has no system-prompt flag, so fold the channel briefing into the
358
+ // FIRST turn's prompt (later turns carry it via the resumed session).
359
+ const prompt =
360
+ !this.sentPreamble && this.systemPreamble.trim().length > 0
361
+ ? `${this.systemPreamble}\n\n---\n\n${text}`
362
+ : text;
363
+ this.sentPreamble = true;
364
+
365
+ const child = this.spawner(this.buildArgs(prompt));
366
+ this.current = child;
367
+
368
+ let turnUsage: AgentUsage = {};
369
+ let sawUsage = false;
370
+ let buf = "";
371
+ const consume = (chunk: string): void => {
372
+ buf += chunk;
373
+ let nl: number;
374
+ while ((nl = buf.indexOf("\n")) >= 0) {
375
+ const line = buf.slice(0, nl);
376
+ buf = buf.slice(nl + 1);
377
+ const { events, sessionId, usage } = mapOpencodeLine(line);
378
+ if (sessionId) this.sessionId = sessionId;
379
+ if (usage) {
380
+ turnUsage = addUsage(turnUsage, usage);
381
+ sawUsage = true;
382
+ }
383
+ for (const e of events) this.emit(e);
384
+ }
385
+ };
386
+ child.stdout?.on("data", (b: Buffer) => consume(b.toString("utf8")));
387
+ let stderr = "";
388
+ child.stderr?.on("data", (b: Buffer) => {
389
+ stderr += b.toString("utf8");
390
+ });
391
+
392
+ await new Promise<void>((resolve) => {
393
+ child.on("exit", (code) => {
394
+ this.current = null;
395
+ if (buf.trim().length > 0) consume("\n"); // flush a trailing line
396
+ if (!this.closed) {
397
+ if (code !== 0) {
398
+ const tail = stderr.trim().slice(-500);
399
+ const message = `opencode exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`;
400
+ this.emit(
401
+ isRateLimitMessage(message)
402
+ ? { type: "error", message, retryable: "rate_limit" }
403
+ : { type: "error", message },
404
+ );
405
+ }
406
+ this.emit({
407
+ type: "turn_complete",
408
+ usage: sawUsage ? turnUsage : undefined,
409
+ });
410
+ }
411
+ resolve();
412
+ });
413
+ child.on("error", (err) => {
414
+ this.current = null;
415
+ if (!this.closed) {
416
+ this.emit({
417
+ type: "error",
418
+ message: `opencode spawn failed: ${err.message}`,
419
+ });
420
+ this.emit({ type: "turn_complete" });
421
+ }
422
+ resolve();
423
+ });
424
+ });
425
+ }
426
+
427
+ async interrupt(): Promise<void> {
428
+ // Kill the running turn's exec. Best-effort — the in-container `opencode`
429
+ // may finish the current tool, but no further output is emitted.
430
+ this.current?.kill("SIGKILL");
431
+ }
432
+
433
+ // `--auto` approves tools (the container is the sandbox), so no permission
434
+ // requests are ever emitted — nothing to resolve.
435
+ async resolvePermission(): Promise<void> {
436
+ /* no-op */
437
+ }
438
+
439
+ async close(): Promise<void> {
440
+ if (this.closed) return;
441
+ this.closed = true;
442
+ this.current?.kill("SIGKILL");
443
+ this.current = null;
444
+ this.emit({ type: "exit", code: 0 });
445
+ this.handlers.clear();
446
+ }
447
+ }
448
+
449
+ // Register the OpenCode adapter at module load (ADR-021/077).
450
+ register({
451
+ kind: "opencode",
452
+ label: "OpenCode",
453
+ supportedModels: () => [...OPENCODE_MODELS],
454
+ defaultModel: OPENCODE_DEFAULT_MODEL,
455
+ supportedEfforts: () => [...OPENCODE_EFFORTS],
456
+ // Gated on the operator's OpenCode auth (copied into containers at task-up).
457
+ // No auth → the engine isn't advertised.
458
+ available: () => existsSync(opencodeAuthPath()),
459
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
460
+ new OpencodeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
461
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Rate-limit / quota classification for agent errors (ADR-076).
3
+ *
4
+ * When an engine reports an error that means "this account is temporarily out
5
+ * of capacity" (usage cap, 429, quota, overloaded), the orchestrator cools the
6
+ * current account and fails over to the next one. Each adapter's pure mapper
7
+ * runs the error text through {@link isRateLimitMessage} and tags the resulting
8
+ * `error` event with `retryable: "rate_limit"` so rotation can act on it
9
+ * without re-parsing engine-specific strings downstream.
10
+ *
11
+ * Deliberately broad but anchored on unambiguous tokens — a false positive
12
+ * only rotates to another account (cheap, self-correcting), a false negative
13
+ * just surfaces the error as today.
14
+ */
15
+ const RATE_LIMIT_PATTERNS: RegExp[] = [
16
+ /rate[\s_-]?limit/i,
17
+ /\b429\b/,
18
+ /too many requests/i,
19
+ /quota/i,
20
+ /usage limit/i,
21
+ /usage cap/i,
22
+ // Claude subscription caps: "You've hit your weekly limit · resets …",
23
+ // "reached your 5-hour limit", "exceeded your limit". Anchored on
24
+ // hit/reached/exceeded so an "approaching your … limit" WARNING won't match
25
+ // (a warning isn't exhaustion — rotating on it would waste the account).
26
+ /(?:hit|reached|exceeded)\s+your\s+.{0,40}?\blimit\b/i,
27
+ /\blimit reached\b/i,
28
+ // A reset hint sitting next to "limit" ("limit · resets Jul 27") is a cap.
29
+ /\blimit\b[^.\n]{0,40}\bresets?\b/i,
30
+ /overloaded/i,
31
+ /capacity/i,
32
+ /resource[\s_-]?exhausted/i,
33
+ /insufficient_quota/i,
34
+ /billing (?:hard )?limit/i,
35
+ ];
36
+
37
+ /** Whether an engine error message looks like a rate-limit / quota exhaustion. */
38
+ export function isRateLimitMessage(message: string | undefined | null): boolean {
39
+ if (!message) return false;
40
+ return RATE_LIMIT_PATTERNS.some((re) => re.test(message));
41
+ }
@@ -114,8 +114,10 @@ export type AgentEvent =
114
114
  /** The turn (one request → response cycle) is done; agent is idle.
115
115
  * `usage` carries this turn's token/cost accounting when the CLI reports it. */
116
116
  | { type: "turn_complete"; usage?: AgentUsage }
117
- /** A recoverable error surfaced by the agent. */
118
- | { type: "error"; message: string }
117
+ /** A recoverable error surfaced by the agent. `retryable` marks errors the
118
+ * orchestrator can fail over from — "rate_limit" triggers account rotation
119
+ * (ADR-076). */
120
+ | { type: "error"; message: string; retryable?: "rate_limit" }
119
121
  /** The underlying process exited. */
120
122
  | { type: "exit"; code: number };
121
123
 
package/lib/codex-auth.ts CHANGED
@@ -40,6 +40,7 @@ import { isNull } from "drizzle-orm";
40
40
 
41
41
  import { getDb, schema } from "./db";
42
42
  import { dockerCli } from "./docker-exec";
43
+ import { provisionEngineAccounts } from "./engine-accounts";
43
44
 
44
45
  /** The exact set task-up.sh copies into `/home/node/.codex`. */
45
46
  const CODEX_ITEMS = [
@@ -197,6 +198,9 @@ export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<v
197
198
  `[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
198
199
  );
199
200
  }
201
+ // ADR-076: also refresh EXTRA config-dir accounts (codex + opencode) so a
202
+ // newly-added second account reaches running tasks, same as the default.
203
+ await provisionEngineAccounts(container, ["codex", "opencode"]);
200
204
  }
201
205
  }
202
206