@kal-elsam/kairo-runtime 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +475 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +683 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1263 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1063 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +444 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. package/src/global/runtime/usage-types.js +62 -0
@@ -0,0 +1,162 @@
1
+ import { spawn as defaultSpawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { resolveHomeDir } from "../paths.js";
4
+
5
+ const GO_URL = "https://opencode.ai/zen/go/v1/usage";
6
+ const DEFAULT_TIMEOUT_MS = 4000;
7
+ // `opencode stats` scans local session history and can take several seconds on
8
+ // larger databases. It runs at most once per five-minute cache window.
9
+ const STATS_TIMEOUT_MS = 20_000;
10
+
11
+ function unknown(error = null, source = "opencode usage") {
12
+ return { status: "unknown", source, windows: [], error: error ? String(error).replace(/(key|token|secret|authorization)[^\s]*/ig, "$1=[redacted]") : null };
13
+ }
14
+
15
+ function clamp(value) {
16
+ const n = Number(value);
17
+ return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : null;
18
+ }
19
+
20
+ export function normalizeOpenCodeGoUsage(payload) {
21
+ const usage = payload?.usage;
22
+ if (!usage || typeof usage !== "object") return null;
23
+ const windows = [];
24
+ for (const [name, value] of Object.entries(usage)) {
25
+ const percent = clamp(value?.percent);
26
+ if (percent == null) continue;
27
+ windows.push({
28
+ name,
29
+ usedPercent: percent,
30
+ remainingPercent: 100 - percent,
31
+ resetsAt: typeof value.resetsAt === "string" ? value.resetsAt : null,
32
+ status: value.status === "rate-limited" ? "rate-limited" : "ok"
33
+ });
34
+ }
35
+ if (!windows.length) return null;
36
+ return {
37
+ status: windows.some((window) => window.status === "rate-limited") ? "rate-limited" : "measured",
38
+ source: GO_URL,
39
+ windows,
40
+ primary: windows.find((window) => window.name === "rolling") ?? null,
41
+ secondary: windows.find((window) => window.name === "weekly") ?? null,
42
+ monthly: windows.find((window) => window.name === "monthly") ?? null
43
+ };
44
+ }
45
+
46
+ async function readGoUsage({ readFile, fetchImpl = globalThis.fetch, authPath, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
47
+ let auth;
48
+ try { auth = JSON.parse(await readFile(authPath, "utf8")); } catch (error) { return unknown(error?.code === "ENOENT" ? "OpenCode Go auth not found" : "invalid OpenCode auth", GO_URL); }
49
+ const credential = auth?.["opencode-go"] ?? (auth?.type === "api" ? auth : null);
50
+ const key = credential?.type === "api" && typeof credential.key === "string" ? credential.key : null;
51
+ if (!key) return unknown("OpenCode Go credential unavailable", GO_URL);
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
+ try {
55
+ const response = await fetchImpl(GO_URL, { method: "GET", headers: { Authorization: `Bearer ${key}` }, signal: controller.signal });
56
+ if (!response?.ok) return unknown(`OpenCode Go usage HTTP ${response?.status ?? "error"}`, GO_URL);
57
+ const parsed = await response.json();
58
+ return normalizeOpenCodeGoUsage(parsed) ?? unknown("unusable OpenCode Go usage", GO_URL);
59
+ } catch (error) { return unknown(error?.name === "AbortError" ? "OpenCode Go usage timeout" : "OpenCode Go usage request failed", GO_URL); }
60
+ finally { clearTimeout(timer); }
61
+ }
62
+
63
+ /** Parse only rows explicitly belonging to OpenCode providers; this is local history, not billing. */
64
+ export function parseOpenCodeStats(text) {
65
+ const stripAnsi = (value) => String(value).replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, "");
66
+ const parseAmount = (value) => {
67
+ const match = String(value).replace(/,/g, "").match(/([\d.]+)\s*([KMB])?/i);
68
+ if (!match) return null;
69
+ const multiplier = { K: 1e3, M: 1e6, B: 1e9 }[String(match[2] ?? "").toUpperCase()] ?? 1;
70
+ return Number(match[1]) * multiplier;
71
+ };
72
+ const records = [];
73
+ let current = null;
74
+ const push = () => {
75
+ if (!current) return;
76
+ current.tokens = (current.inputTokens ?? 0) + (current.outputTokens ?? 0)
77
+ + (current.cacheRead ?? 0) + (current.cacheWrite ?? 0) || null;
78
+ if (current.cost != null || current.tokens != null) records.push(current);
79
+ current = null;
80
+ };
81
+ for (const rawLine of String(text ?? "").split(/\r?\n/)) {
82
+ const line = stripAnsi(rawLine);
83
+ const model = line.match(/(?:│|\|)?\s*((?:opencode-go|opencode)\/[^\s│|]+)/i)?.[1];
84
+ if (model) {
85
+ push();
86
+ current = { model, provider: model.startsWith("opencode-go/") ? "opencode-go" : "opencode", cost: null, inputTokens: null, outputTokens: null, cacheRead: null, cacheWrite: null };
87
+ continue;
88
+ }
89
+ if (!current) continue;
90
+ const input = line.match(/Input Tokens\s+([\d.,]+\s*[KMB]?)/i)?.[1];
91
+ const output = line.match(/Output Tokens\s+([\d.,]+\s*[KMB]?)/i)?.[1];
92
+ const cost = line.match(/Cost\s+\$\s*([\d.,]+\s*[KMB]?)/i)?.[1];
93
+ if (input) current.inputTokens = parseAmount(input);
94
+ if (output) current.outputTokens = parseAmount(output);
95
+ const cacheRead = line.match(/Cache Read\s+([\d.,]+\s*[KMB]?)/i)?.[1];
96
+ const cacheWrite = line.match(/Cache Write\s+([\d.,]+\s*[KMB]?)/i)?.[1];
97
+ if (cacheRead) current.cacheRead = parseAmount(cacheRead);
98
+ if (cacheWrite) current.cacheWrite = parseAmount(cacheWrite);
99
+ if (cost) current.cost = parseAmount(cost);
100
+ }
101
+ push();
102
+ const zenRecords = records.filter((record) => record.provider === "opencode");
103
+ if (!zenRecords.length) return null;
104
+ return {
105
+ status: "local_recorded",
106
+ source: "opencode stats --days 7 --models",
107
+ billing: "PAYG",
108
+ balance: "unknown",
109
+ autoReload: "unknown",
110
+ kairoPolicy: "PAYG blocked",
111
+ days: 7,
112
+ records: zenRecords,
113
+ goRecords: records.filter((record) => record.provider === "opencode-go"),
114
+ totalCost: zenRecords.reduce((sum, record) => sum + (record.cost ?? 0), 0),
115
+ totalTokens: zenRecords.reduce((sum, record) => sum + (record.tokens ?? 0), 0)
116
+ };
117
+ }
118
+
119
+ function readStats({ spawn = defaultSpawn, timeoutMs = STATS_TIMEOUT_MS } = {}) {
120
+ return new Promise((resolve) => {
121
+ let child;
122
+ try { child = spawn("opencode", ["stats", "--days", "7", "--models"], { stdio: ["ignore", "pipe", "pipe"] }); }
123
+ catch { resolve(unknown("OpenCode stats unavailable", "opencode stats --days 7 --models")); return; }
124
+ let output = "";
125
+ let settled = false;
126
+ const timer = setTimeout(() => finish(unknown("OpenCode stats timeout", "opencode stats --days 7 --models")), timeoutMs);
127
+ const finish = (value) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill?.(); } catch {} resolve(value); };
128
+ child.stdout?.on("data", (chunk) => { output += String(chunk).slice(0, 100_000 - output.length); });
129
+ child.once?.("error", () => finish(unknown("OpenCode stats failed", "opencode stats --days 7 --models")));
130
+ child.once?.("close", () => finish(parseOpenCodeStats(output) ?? unknown("unparseable OpenCode stats", "opencode stats --days 7 --models")));
131
+ });
132
+ }
133
+
134
+ export async function readOpenCodeGoUsage({
135
+ readFile = (path, encoding) => import("node:fs/promises").then((fs) => fs.readFile(path, encoding)),
136
+ fetchImpl = globalThis.fetch,
137
+ homeDir = resolveHomeDir(),
138
+ authPath = join(homeDir, ".local/share/opencode/auth.json"),
139
+ timeoutMs = DEFAULT_TIMEOUT_MS
140
+ } = {}) {
141
+ return readGoUsage({ readFile, fetchImpl, authPath, timeoutMs });
142
+ }
143
+
144
+ export async function readOpenCodeStats({ spawn = defaultSpawn, timeoutMs = STATS_TIMEOUT_MS } = {}) {
145
+ return readStats({ spawn, timeoutMs });
146
+ }
147
+
148
+ export async function readOpenCodeUsage({
149
+ readFile = (path, encoding) => import("node:fs/promises").then((fs) => fs.readFile(path, encoding)),
150
+ fetchImpl = globalThis.fetch,
151
+ spawn = defaultSpawn,
152
+ homeDir = resolveHomeDir(),
153
+ authPath = join(homeDir, ".local/share/opencode/auth.json"),
154
+ timeoutMs = DEFAULT_TIMEOUT_MS,
155
+ statsTimeoutMs = STATS_TIMEOUT_MS
156
+ } = {}) {
157
+ const [go, zen] = await Promise.all([
158
+ readOpenCodeGoUsage({ readFile, fetchImpl, authPath, homeDir, timeoutMs }),
159
+ readOpenCodeStats({ spawn, timeoutMs: statsTimeoutMs })
160
+ ]);
161
+ return { go, zen };
162
+ }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { isAbsolute, join, relative } from "node:path";
3
3
 
4
4
  export const HARNESS_DIR_NAME = ".harness";
5
5
 
@@ -24,7 +24,11 @@ export function harnessHomePaths(homeDir) {
24
24
  monitorStatePath: join(root, "monitor", "state.json"),
25
25
  coreDir: join(root, "core"),
26
26
  backupsDir: join(root, "backups"),
27
- sessionsDir: join(root, "sessions")
27
+ sessionsDir: join(root, "sessions"),
28
+ worktreesDir: join(root, "worktrees"),
29
+ usageDir: join(root, "usage"),
30
+ modelIntelligencePath: join(root, "model-intelligence.json"),
31
+ huggingfaceLeaderboardPath: join(root, "huggingface-leaderboard.json")
28
32
  };
29
33
  }
30
34
 
@@ -39,3 +43,46 @@ export function runPaths(homeDir, runId) {
39
43
  transcriptPath: join(runDir, "transcript.jsonl")
40
44
  };
41
45
  }
46
+
47
+ /**
48
+ * The exact shape createWorktreeId() (execution-worktree-types.js)
49
+ * produces — `wt_` plus two base36 segments. Enforced here, at the one
50
+ * real boundary every worktree path gets built from, rather than trusted
51
+ * by each caller separately: an unvalidated id (e.g. "../../escape")
52
+ * would otherwise let `join()` resolve outside worktreesDir entirely.
53
+ */
54
+ export function assertWorktreeId(worktreeId) {
55
+ if (typeof worktreeId !== "string" || !/^wt_[a-z0-9]{1,20}_[a-z0-9]{1,20}$/.test(worktreeId)) {
56
+ throw new Error(`Invalid worktree id "${worktreeId ?? ""}".`);
57
+ }
58
+ return worktreeId;
59
+ }
60
+
61
+ /**
62
+ * Every real path an execution worktree needs — the real checked-out git
63
+ * worktree itself (`treePath`, never the project's own directory) plus its
64
+ * own metadata, all scoped under this one worktreeDir so a single
65
+ * `rm -rf` (see execution-worktree-manager.js's rollback) fully undoes an
66
+ * incomplete creation. `worktreeId` is validated here, the one real
67
+ * boundary every consumer of this function goes through — never trusted
68
+ * or re-validated ad hoc by callers.
69
+ */
70
+ export function worktreePaths(homeDir, worktreeId) {
71
+ assertWorktreeId(worktreeId);
72
+ const { worktreesDir } = harnessHomePaths(homeDir);
73
+ const worktreeDir = join(worktreesDir, worktreeId);
74
+ // Defense in depth alongside the regex above — the same real containment
75
+ // check architect-store.js's taskPaths already applies to task ids.
76
+ const rel = relative(worktreesDir, worktreeDir);
77
+ const isInside = rel !== "" && !isAbsolute(rel) && rel !== ".." && !rel.startsWith("../");
78
+ if (!isInside) {
79
+ throw new Error(`Worktree path escapes worktreesDir for id "${worktreeId}".`);
80
+ }
81
+
82
+ return {
83
+ worktreeDir,
84
+ treePath: join(worktreeDir, "tree"),
85
+ statePath: join(worktreeDir, "state.json"),
86
+ checkpointsPath: join(worktreeDir, "checkpoints.jsonl")
87
+ };
88
+ }
@@ -5,6 +5,7 @@ import { harnessHomePaths } from "./paths.js";
5
5
  import { AGENT_CAPABILITY_IDS } from "./agent-capabilities/index.js";
6
6
  import { classifyCustomBaseUrl, isValidEnvironmentName } from "./intelligence/custom-url.js";
7
7
  import { validateRuntimeProfile, RUNTIME_PROFILE_KEYS } from "./runtime/run-profile.js";
8
+ import { EXECUTION_ADAPTER_IDS } from "./runtime/execution-adapters/index.js";
8
9
 
9
10
  export const PROFILE_KEYS = new Set([
10
11
  "coordinator",
@@ -17,6 +18,7 @@ export const PROFILE_KEYS = new Set([
17
18
  "tokenBudget",
18
19
  "stableContextBudget",
19
20
  "requestContextBudget",
21
+ "providerTokenBudgets",
20
22
  "customProviders",
21
23
  ...RUNTIME_PROFILE_KEYS
22
24
  ]);
@@ -32,6 +34,7 @@ export const DEFAULT_PROFILE = {
32
34
  tokenBudget: null,
33
35
  stableContextBudget: null,
34
36
  requestContextBudget: null,
37
+ providerTokenBudgets: {},
35
38
  customProviders: [],
36
39
  agentAliases: {},
37
40
  modelAliases: {},
@@ -112,6 +115,7 @@ export function buildProfileJson(resolved) {
112
115
  tokenBudget: profile.tokenBudget,
113
116
  stableContextBudget: profile.stableContextBudget,
114
117
  requestContextBudget: profile.requestContextBudget,
118
+ providerTokenBudgets: profile.providerTokenBudgets ?? {},
115
119
  customProviders: sanitizeCustomProviders(profile.customProviders),
116
120
  agentAliases: profile.agentAliases ?? {},
117
121
  modelAliases: profile.modelAliases ?? {},
@@ -191,6 +195,8 @@ function validateProfile(profile) {
191
195
  }
192
196
  }
193
197
 
198
+ validateProviderTokenBudgets(profile.providerTokenBudgets);
199
+
194
200
  if (profile.preferredBackend != null && typeof profile.preferredBackend !== "string") {
195
201
  throw new Error("Profile preferredBackend must be a string or null.");
196
202
  }
@@ -213,7 +219,8 @@ const SECRET_KEY_ALLOWLIST = new Set([
213
219
  "api_key_env",
214
220
  "token_budget",
215
221
  "stable_context_budget",
216
- "request_context_budget"
222
+ "request_context_budget",
223
+ "provider_token_budgets"
217
224
  ]);
218
225
  const SECRET_VALUE_PATTERN = /^(sk-[A-Za-z0-9]|sk-or-|gh[pousr]_|xox[baprs]-|AKIA[0-9A-Z]{16}\b|Bearer\s+\S+|eyJ[A-Za-z0-9_-]+\.)|-----BEGIN [A-Z ]*PRIVATE KEY-----/i;
219
226
 
@@ -265,6 +272,21 @@ function walkForSecrets(value) {
265
272
  }
266
273
  }
267
274
 
275
+ function validateProviderTokenBudgets(budgets) {
276
+ if (budgets == null) return;
277
+ if (typeof budgets !== "object" || Array.isArray(budgets)) {
278
+ throw new Error("Profile providerTokenBudgets must be an object mapping provider id to a positive number.");
279
+ }
280
+ for (const [provider, budget] of Object.entries(budgets)) {
281
+ if (!EXECUTION_ADAPTER_IDS.includes(provider)) {
282
+ throw new Error(`Unknown provider "${provider}" in providerTokenBudgets. Use ${EXECUTION_ADAPTER_IDS.join(", ")}.`);
283
+ }
284
+ if (!Number.isFinite(budget) || budget < 1) {
285
+ throw new Error(`Profile providerTokenBudgets.${provider} must be a positive number.`);
286
+ }
287
+ }
288
+ }
289
+
268
290
  function validateCustomProviders(providers) {
269
291
  if (providers == null) return;
270
292
  if (!Array.isArray(providers)) {
@@ -1,40 +1,77 @@
1
1
  import { createExecutionAdapter, parseNdjsonLine, buildPermissionsArgs } from "./create-execution-adapter.js";
2
+ import {
3
+ ReviewExecError, assertBoundedProcessOk, runBoundedProcess
4
+ } from "../review/review-exec.js";
2
5
 
3
6
  const EXECUTABLE = "claude";
7
+ const AUTH_TIMEOUT_MS = 10_000;
8
+ const AUTH_OUTPUT_LIMIT = 16_384;
9
+ const SAFE_ENV_KEYS = Object.freeze([
10
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
11
+ "TMPDIR", "TERM", "CLAUDE_CONFIG_DIR", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
12
+ "http_proxy", "https_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS"
13
+ ]);
14
+ const SUBSCRIPTION_TYPE = /^(?:pro|max|team|enterprise)(?:[-_ ].*)?$/i;
4
15
 
5
- function buildClaudeLaunch({ task, cwd, model, permissions = [] }) {
6
- const args = [
7
- "-p",
8
- "--output-format",
9
- "stream-json",
10
- ...buildPermissionsArgs(permissions),
11
- task
12
- ];
16
+ export const CLAUDE_AUTH_ERRORS = Object.freeze({
17
+ SUBSCRIPTION_REQUIRED: "claude_subscription_required",
18
+ INVALID_STATUS: "claude_auth_status_invalid"
19
+ });
13
20
 
14
- if (model) {
15
- args.unshift("--model", model);
21
+ export function buildClaudeExecutionEnv(sourceEnv = process.env) {
22
+ const env = Object.create(null);
23
+ for (const key of SAFE_ENV_KEYS) {
24
+ if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
16
25
  }
26
+ return env;
27
+ }
17
28
 
18
- return {
29
+ export async function verifyClaudeSubscriptionAuth({
30
+ env = process.env, runProcess = runBoundedProcess
31
+ } = {}) {
32
+ const result = await runProcess({
19
33
  command: EXECUTABLE,
20
- args,
21
- cwd,
22
- env: process.env
23
- };
34
+ args: ["auth", "status"],
35
+ env: buildClaudeExecutionEnv(env),
36
+ stdin: null,
37
+ timeoutMs: AUTH_TIMEOUT_MS,
38
+ stdoutLimit: AUTH_OUTPUT_LIMIT,
39
+ stderrLimit: AUTH_OUTPUT_LIMIT
40
+ });
41
+ assertBoundedProcessOk(result);
42
+ let status;
43
+ try { status = JSON.parse(String(result.stdout ?? "").trim()); }
44
+ catch {
45
+ throw new ReviewExecError("Claude auth status did not return valid JSON.", {
46
+ code: CLAUDE_AUTH_ERRORS.INVALID_STATUS
47
+ });
48
+ }
49
+ if (status?.loggedIn !== true || status?.authMethod !== "claude.ai"
50
+ || status?.apiProvider !== "firstParty" || !SUBSCRIPTION_TYPE.test(status?.subscriptionType ?? "")) {
51
+ throw new ReviewExecError(
52
+ "Claude execution requires a first-party claude.ai Pro, Max, Team, or Enterprise subscription.",
53
+ { code: CLAUDE_AUTH_ERRORS.SUBSCRIPTION_REQUIRED }
54
+ );
55
+ }
56
+ return { mode: "subscription", subscriptionType: status.subscriptionType };
57
+ }
58
+
59
+ export function buildClaudeLaunch({ task, cwd, model, permissions = [], env = process.env }) {
60
+ const unsafeArgs = buildPermissionsArgs(permissions);
61
+ const permissionArgs = unsafeArgs.length > 0
62
+ ? unsafeArgs
63
+ : ["--permission-mode", "auto", "--permission-prompts", "none"];
64
+ const args = ["-p", "--output-format", "stream-json", ...permissionArgs, task];
65
+ if (model) args.unshift("--model", model);
66
+ return { command: EXECUTABLE, args, cwd, env: buildClaudeExecutionEnv(env) };
24
67
  }
25
68
 
26
69
  function parseClaudeEventLine(line) {
27
70
  const parsed = parseNdjsonLine(line);
28
71
  if (!parsed || typeof parsed !== "object") return null;
29
-
30
72
  if (parsed.type === "tool_use" || parsed.type === "tool_call") {
31
- return {
32
- type: "tool_call",
33
- tool_name: parsed.name ?? parsed.tool ?? "unknown",
34
- status: parsed.status ?? "started"
35
- };
73
+ return { type: "tool_call", tool_name: parsed.name ?? parsed.tool ?? "unknown", status: parsed.status ?? "started" };
36
74
  }
37
-
38
75
  if (parsed.type === "usage" || parsed.usage) {
39
76
  const usage = parsed.usage ?? parsed;
40
77
  return {
@@ -45,7 +82,6 @@ function parseClaudeEventLine(line) {
45
82
  cost: usage.cost ?? null
46
83
  };
47
84
  }
48
-
49
85
  return parsed;
50
86
  }
51
87
 
@@ -54,13 +90,10 @@ export default createExecutionAdapter({
54
90
  label: "Claude Code",
55
91
  executable: EXECUTABLE,
56
92
  capabilities: {
57
- structuredEvents: true,
58
- tokens: true,
59
- diff: false,
60
- cancel: true,
61
- transcript: true,
62
- permissionModes: ["force", "yolo"]
93
+ structuredEvents: true, tokens: true, diff: false, cancel: true, transcript: true,
94
+ permissionModes: ["safe", "force", "yolo"]
63
95
  },
64
96
  buildLaunch: buildClaudeLaunch,
65
- parseEventLine: parseClaudeEventLine
97
+ parseEventLine: parseClaudeEventLine,
98
+ preflight: verifyClaudeSubscriptionAuth
66
99
  });
@@ -1,4 +1,5 @@
1
1
  import { createExecutionAdapter, parseNdjsonLine } from "./create-execution-adapter.js";
2
+ import { verifyCodexSubscriptionAuth } from "../../architect/architect-codex.js";
2
3
 
3
4
  const EXECUTABLE = "codex";
4
5
 
@@ -13,7 +14,12 @@ function buildCodexPermissionsArgs(permissions = []) {
13
14
  return ["--dangerously-bypass-approvals-and-sandbox"];
14
15
  }
15
16
 
16
- return [];
17
+ // Safe headless default: sandboxed to the workspace, with approval
18
+ // requests auto-reviewed instead of blocking forever on a human who
19
+ // can't respond in a non-interactive run — mirrors claude.js's
20
+ // `--permission-mode auto --permission-prompts none`. Without this,
21
+ // `codex exec` hangs on its default on-request approval policy.
22
+ return ["--sandbox", "workspace-write", "--approve-for-me"];
17
23
  }
18
24
 
19
25
  function buildCodexLaunch({ task, cwd, model, permissions = [] }) {
@@ -76,5 +82,6 @@ export default createExecutionAdapter({
76
82
  permissionModes: ["yolo"]
77
83
  },
78
84
  buildLaunch: buildCodexLaunch,
79
- parseEventLine: parseCodexEventLine
85
+ parseEventLine: parseCodexEventLine,
86
+ preflight: verifyCodexSubscriptionAuth
80
87
  });
@@ -11,7 +11,8 @@ export function createExecutionAdapter({
11
11
  buildLaunch,
12
12
  parseEventLine = null,
13
13
  checkAvailability = null,
14
- launchable = null
14
+ launchable = null,
15
+ preflight = null
15
16
  }) {
16
17
  return {
17
18
  id,
@@ -63,6 +64,10 @@ export function createExecutionAdapter({
63
64
  return buildLaunch(options);
64
65
  },
65
66
 
67
+ async preflight(context = {}) {
68
+ return preflight ? preflight(context) : { ok: true };
69
+ },
70
+
66
71
  parseEventLine(line, context = {}) {
67
72
  if (!parseEventLine) return null;
68
73
  return parseEventLine(line, context);
@@ -1,39 +1,104 @@
1
- import { createExecutionAdapter } from "./create-execution-adapter.js";
1
+ import { createExecutionAdapter, parseNdjsonLine } from "./create-execution-adapter.js";
2
+ import { isExecutableAvailable } from "../../cli-probe.js";
2
3
 
3
4
  const EXECUTABLE = "opencode";
4
5
 
6
+ // Verified live (`opencode run --format json`) against a real account: the
7
+ // CLI genuinely emits parseable NDJSON events — step_start/step_finish,
8
+ // tool_use (with real tool name + status), text, and error — including
9
+ // real per-step `tokens` {input, output, reasoning, cache} and `cost` on
10
+ // step_finish. That earlier "does not emit auditable structured events"
11
+ // claim was wrong; structuredEvents below is now an accurate capability.
12
+ //
13
+ // It stays `launchable: false` anyway, for a different, harder reason:
14
+ // this adapter is shared by both OpenCode Go (subscription, $10/mo) and
15
+ // OpenCode Zen (PAYG) — same `opencode` executable, same event shapes,
16
+ // no field in any event that names which product tier actually served
17
+ // the request. A live invocation with `-m opencode/<id>` for a model that
18
+ // exists on BOTH tiers was confirmed to be ambiguous — no way to prove
19
+ // after the fact whether it ran on Go or silently billed against Zen. A
20
+ // second invocation with the Go-specific `opencode-go/<id>` prefix simply
21
+ // hung. Per explicit decision: Kairo must not route real tasks through
22
+ // this adapter until it can prove, from the run's own evidence, both (1)
23
+ // the effective provider actually used was opencode-go, and (2) Zen was
24
+ // not touched. Until that receipt-level proof exists, this stays blocked
25
+ // — no more paid trial invocations to "just check" this again.
26
+ function checkOpencodeAvailability(context = {}) {
27
+ const available = isExecutableAvailable(EXECUTABLE, { env: context.env ?? process.env });
28
+ if (!available) {
29
+ return {
30
+ available: false, compatible: false, launchable: false,
31
+ reason: `OpenCode CLI "${EXECUTABLE}" is not on PATH.`
32
+ };
33
+ }
34
+ return {
35
+ available: true, compatible: true, launchable: false,
36
+ reason: "OpenCode Go/Zen share one executable with no per-event way to prove which product tier actually served a run — blocked until that provider-isolation evidence exists."
37
+ };
38
+ }
39
+
5
40
  function buildOpencodeLaunch({ task, cwd, model, permissions = [] }) {
6
- const args = ["run", task];
41
+ // Not the adapter's call to make: which provider prefix ("opencode/" vs
42
+ // "opencode-go/") is safe for a given model id is exactly the unresolved
43
+ // question above. Whatever fully-qualified model ref the caller supplies
44
+ // is passed through unmodified, same as codex.js/claude.js do.
45
+ const args = ["run", "--format", "json"];
46
+ const normalized = permissions.map((entry) => String(entry).toLowerCase());
47
+ if (normalized.includes("yolo") || normalized.includes("force") || normalized.includes("all")) {
48
+ args.push("--auto");
49
+ }
50
+ if (model) args.push("--model", model);
51
+ args.push(task);
52
+ return { command: EXECUTABLE, args, cwd, env: process.env };
53
+ }
54
+
55
+ /**
56
+ * Verified against real captured output (`opencode run --format json`),
57
+ * not guessed: tool_use/tool -> tool_call, step_finish's real per-step
58
+ * tokens+cost -> usage, error -> passed through raw (no invented shape
59
+ * for a case never seen with a matching one here). Everything else
60
+ * (step_start, text) passes through raw, same fallback as the other
61
+ * adapters' parsers.
62
+ */
63
+ export function parseOpencodeEventLine(line) {
64
+ const parsed = parseNdjsonLine(line);
65
+ if (!parsed || typeof parsed !== "object") return null;
7
66
 
8
- if (model) {
9
- args.unshift("--model", model);
67
+ if (parsed.type === "tool_use" && parsed.part?.type === "tool") {
68
+ return {
69
+ type: "tool_call",
70
+ tool_name: parsed.part.tool ?? "unknown",
71
+ status: parsed.part.state?.status ?? "started"
72
+ };
10
73
  }
11
74
 
12
- if (permissions.includes("force") || permissions.includes("all")) {
13
- args.unshift("--force");
75
+ if (parsed.type === "step_finish" && parsed.part?.tokens) {
76
+ const tokens = parsed.part.tokens;
77
+ return {
78
+ type: "usage",
79
+ inputTokens: tokens.input ?? null,
80
+ outputTokens: tokens.output ?? null,
81
+ totalTokens: tokens.total ?? null,
82
+ cost: parsed.part.cost ?? null
83
+ };
14
84
  }
15
85
 
16
- return {
17
- command: EXECUTABLE,
18
- args,
19
- cwd,
20
- env: process.env
21
- };
86
+ return parsed;
22
87
  }
23
88
 
24
89
  export default createExecutionAdapter({
25
90
  id: "opencode",
26
91
  label: "OpenCode",
27
92
  executable: EXECUTABLE,
28
- launchable: false,
29
93
  capabilities: {
30
- structuredEvents: false,
31
- tokens: false,
94
+ structuredEvents: true,
95
+ tokens: true,
32
96
  diff: false,
33
97
  cancel: true,
34
- transcript: false,
35
- permissionModes: ["force"]
98
+ transcript: true,
99
+ permissionModes: ["force", "yolo"]
36
100
  },
101
+ checkAvailability: checkOpencodeAvailability,
37
102
  buildLaunch: buildOpencodeLaunch,
38
- parseEventLine: null
103
+ parseEventLine: parseOpencodeEventLine
39
104
  });