@runuai/host 0.8.34 → 0.8.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +24 -0
- package/images/standard/Dockerfile +9 -0
- package/lib/agents/claude.ts +7 -1
- package/lib/agents/codex.ts +8 -2
- package/lib/agents/factory.ts +1 -0
- package/lib/agents/opencode.ts +461 -0
- package/lib/agents/rate-limit.ts +34 -0
- package/lib/agents/types.ts +4 -2
- package/lib/codex-auth.ts +4 -0
- package/lib/engine-accounts.ts +538 -0
- package/lib/engines.ts +69 -4
- package/lib/mcp-gateway.ts +30 -5
- package/lib/orchestrator.ts +168 -7
- package/lib/standard-image.ts +5 -1
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +17 -0
- package/src/ui/server.ts +65 -0
- package/src/ui/types.ts +22 -2
- package/ui/app.js +190 -1
- package/ui/style.css +19 -0
|
@@ -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
|
+
);
|
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
|
# ---------------------------------------------------------------------------
|
package/lib/agents/claude.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/agents/codex.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
153
|
+
return isRateLimitMessage(message)
|
|
154
|
+
? [{ type: "error", message, retryable: "rate_limit" }]
|
|
155
|
+
: [{ type: "error", message }];
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
return [];
|
package/lib/agents/factory.ts
CHANGED
|
@@ -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,34 @@
|
|
|
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
|
+
/reached your (?:usage|limit)/i,
|
|
23
|
+
/overloaded/i,
|
|
24
|
+
/capacity/i,
|
|
25
|
+
/resource[\s_-]?exhausted/i,
|
|
26
|
+
/insufficient_quota/i,
|
|
27
|
+
/billing (?:hard )?limit/i,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Whether an engine error message looks like a rate-limit / quota exhaustion. */
|
|
31
|
+
export function isRateLimitMessage(message: string | undefined | null): boolean {
|
|
32
|
+
if (!message) return false;
|
|
33
|
+
return RATE_LIMIT_PATTERNS.some((re) => re.test(message));
|
|
34
|
+
}
|
package/lib/agents/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|