@kal-elsam/kairo-runtime 0.21.0 → 0.22.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.22.1 — 2026-09-18 (Kairo Runtime)
9
+
10
+ Patch release.
11
+
12
+ ### Fixed
13
+
14
+ - Execution links no longer hardcode `provider: "claude"` on every
15
+ write regardless of which real adapter (Codex, Cursor, OpenCode Go,
16
+ Claude) actually executed the task. `provider` now mirrors the
17
+ link's own real `agentId`, matching what `service.js`'s
18
+ `executionFor` already treated as ground truth at read time. The
19
+ cockpit reads this field directly for its provider display, so every
20
+ task's shown execution provider had always silently said "claude"
21
+ regardless of what actually ran it.
22
+
23
+ ## 0.22.0 — 2026-09-18 (Kairo Runtime)
24
+
25
+ Minor release. Promotes OpenCode Go to a real automatic execution
26
+ provider.
27
+
28
+ ### Changed
29
+
30
+ - OpenCode Go's accessMode flips from "manual" to "automatic" — its own
31
+ dedicated `/zen/go/*` gateway is a real, separate endpoint from Zen's,
32
+ resolving the earlier billing-attribution concern for Go specifically.
33
+ OpenCode Zen keeps its own real, unresolved Go/Zen billing-attribution
34
+ gap and stays manual.
35
+ - Adds an adapter-declared idle timeout (60s for OpenCode, reset on
36
+ every real output chunk — never an absolute one, so a real
37
+ long-running task is never killed just for taking a while) as the
38
+ safety net for a live-reproduced CLI hang: a genuine hang now becomes
39
+ a bounded, detectable failure instead of an indefinite one.
40
+
41
+ ### Fixed
42
+
43
+ - Three previously-latent bugs, never exercised since Go was never
44
+ launchable before: `resolveExecutionAdapter` threw for
45
+ "opencode-go"/"opencode-zen"; real task execution launched with the
46
+ catalog's bare model id instead of the real "opencode-go/<id>"
47
+ form the CLI needs to route deterministically; the usage/quota
48
+ tracking store rejected "opencode-go"/"opencode-zen" as valid
49
+ providers despite their genuinely separate budgets.
50
+
8
51
  ## 0.21.0 — 2026-09-18 (Kairo Runtime)
9
52
 
10
53
  Patch-level polish. When a role resolves to a manual-only provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kal-elSam/harness#readme",
@@ -307,23 +307,41 @@ export async function readExecutionLink(projectRoot, taskId) {
307
307
  const stat = await statOrNull(paths.executionPath);
308
308
  if (!stat) return null;
309
309
  const link = JSON.parse(await readFile(paths.executionPath, "utf8"));
310
+ // `provider` is real, structural validation only (a non-empty real
311
+ // string) — never a single hardcoded value. PROJECT TEAM can route a
312
+ // real task to any real adapter (Codex, Claude, Cursor, OpenCode Go),
313
+ // and a legacy artifact from before that existed still carries "claude"
314
+ // for real, so this must accept every real one honestly, never lie
315
+ // by forcing them all to read back as "claude".
310
316
  if (link?.schema !== EXECUTION_SCHEMA || link?.taskId !== taskId
311
- || !/^run_[a-z0-9_]+$/.test(link?.runId ?? "") || link?.provider !== "claude") {
317
+ || !/^run_[a-z0-9_]+$/.test(link?.runId ?? "") || typeof link?.provider !== "string" || link.provider.length === 0) {
312
318
  throw new Error(`Invalid execution artifact: ${taskId}`);
313
319
  }
314
320
  return link;
315
321
  }
316
322
 
323
+ /**
324
+ * `provider` always mirrors the link's own real `agentId` — the field a
325
+ * real caller (executePlan) actually sets, and the one PROJECT TEAM's own
326
+ * routing decision already named. Never a fabricated or hardcoded value;
327
+ * a legacy caller that never set agentId falls back to "claude" only
328
+ * because that's genuinely what every execution WAS before PROJECT TEAM's
329
+ * multi-provider routing existed — never a guess for real, current data.
330
+ */
331
+ function resolveExecutionProvider(link) {
332
+ return link?.agentId ?? link?.provider ?? "claude";
333
+ }
334
+
317
335
  export async function writeExecutionLink(projectRoot, taskId, link) {
318
336
  const paths = await prepareTaskDirectory(projectRoot, taskId);
319
- const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: "claude" };
337
+ const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: resolveExecutionProvider(link) };
320
338
  await writeAtomicJson(paths.executionPath, value, { createExclusive: true });
321
339
  return value;
322
340
  }
323
341
 
324
342
  export async function updateExecutionLink(projectRoot, taskId, link) {
325
343
  const paths = await prepareTaskDirectory(projectRoot, taskId);
326
- const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: "claude" };
344
+ const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: resolveExecutionProvider(link) };
327
345
  await writeAtomicJson(paths.executionPath, value);
328
346
  return value;
329
347
  }
@@ -44,6 +44,7 @@ import {
44
44
  buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
45
45
  } from "../intelligence/model-intelligence.js";
46
46
  import { buildAutomaticExecutionPool, buildCompleteCandidateCatalog, buildRecommendationPool } from "../intelligence/model-candidate-catalog.js";
47
+ import { toRuntimeModelRef } from "../intelligence/transport-registry.js";
47
48
  import { createCapabilityRegistry } from "../intelligence/model-capability-registry.js";
48
49
  import { ingestArtificialAnalysisEvidence, ingestHuggingFaceLeaderboardEvidence } from "../intelligence/model-capability-registry-sources.js";
49
50
  import { ingestOfficialSnapshotEvidence } from "../intelligence/official-benchmark-snapshots.js";
@@ -1024,7 +1025,15 @@ export function createConversationService(deps = {}) {
1024
1025
  throw new Error(`Cannot execute "${taskId}": the real project team state changed since this was confirmed (strategy, eligibility, or override) — request a new preview and confirm again.`);
1025
1026
  }
1026
1027
  const resolvedAgentId = resolvedCandidate.adapterId;
1027
- const resolvedModel = resolvedCandidate.modelId;
1028
+ // OpenCode Go/Zen's real catalog stores bare model ids (see
1029
+ // opencode-models.js's normalizeModel) — the CLI needs the real,
1030
+ // fully-qualified "opencode-go/<id>" (or "opencode/<id>" for Zen)
1031
+ // ref to deterministically route to the intended product; a bare id
1032
+ // is exactly the ambiguity Kairo must never risk. Every other
1033
+ // adapter's modelId is already launch-ready as-is.
1034
+ const resolvedModel = resolvedAgentId === "opencode-go" || resolvedAgentId === "opencode-zen"
1035
+ ? toRuntimeModelRef(resolvedAgentId === "opencode-go" ? "go" : "zen", resolvedCandidate.modelId)
1036
+ : resolvedCandidate.modelId;
1028
1037
 
1029
1038
  const runId = newRunId();
1030
1039
  const createdAt = new Date().toISOString();
@@ -174,15 +174,11 @@ export function checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, o
174
174
  const adapter = findAdapter(adapterId, adapters);
175
175
  if (!adapter) return { ok: false, reason: `${adapterId}: no adapter found` };
176
176
  if (!adapter.available) return { ok: false, reason: adapter.reason ?? `${adapterId}: not available` };
177
- // "launchable" means safe for Kairo to invoke programmatically — not the
178
- // bar a manual recommendation needs. OpenCode Go alone keeps the
179
- // exemption: real availability is enough to recommend it, even though
180
- // Kairo can't safely auto-execute through it yet (unresolved Go/Zen
181
- // billing-attribution gap see opencode.js). Cursor no longer needs
182
- // this exemption — its real launchability is judged the same way for
183
- // both recommendation and execution now.
184
- const launchableRequired = requireLaunchable || adapterId !== "opencode-go";
185
- if (launchableRequired && !adapter.launchable) return { ok: false, reason: adapter.reason ?? `${adapterId}: not launchable yet` };
177
+ // "launchable" means safe for Kairo to invoke programmatically — every
178
+ // real adapter is now judged the same way for both recommendation and
179
+ // execution; no adapter keeps a special exemption anymore (Cursor and
180
+ // OpenCode Go both dropped theirs once their real adapters proved out).
181
+ if (!adapter.launchable) return { ok: false, reason: adapter.reason ?? `${adapterId}: not launchable yet` };
186
182
 
187
183
  if (adapterId === "codex") {
188
184
  const left = remainingPercent(codexUsage);
@@ -39,7 +39,7 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
39
39
  * @property {string} modelName - human-readable clean name, with real effort/context/privacy variant tokens stripped (see stripDisplayVariant). Never invented — always derived from the provider's own real displayName.
40
40
  * @property {string} rawDisplayName - the provider's own displayName, completely unmodified — the real evidence modelName was derived from. Whatever stripDisplayVariant peeled off (effort/context/privacy tokens) to produce modelName is still visible here, never a separate field: /models --evidence's own "technical detail" is just this string.
41
41
  * @property {string} adapterId - "codex" | "claude" | "cursor" | "opencode-go".
42
- * @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor's own "auto" router model always; OpenCode Go until its own empirical automatic-execution proof lands — see this module's own doc). Named Cursor models are automatic.
42
+ * @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor's own "auto" router model always; OpenCode Zen always, pending its own Go/Zen billing-attribution proof — see this module's own doc). Named Cursor and OpenCode Go models are automatic.
43
43
  * @property {"scored"|"partial"|"unscored"} evidenceStatus - "scored": AA matched this exact model AND reports at least one of intelligenceIndex/codingIndex. "partial": AA matched it but both composite indices are null (real match, thin evidence). "unscored": no confident AA match at all. Never role-specific — see this module's own doc for why.
44
44
  * @property {string|null} lineageKey - real, recognized model family/lineage (see LINEAGE_PARSERS) — null when the modelId doesn't match any recognized, conservative pattern. Never guessed.
45
45
  * @property {number|null} generation - a real, comparable version number within that lineage — null whenever lineageKey is null.
@@ -281,20 +281,27 @@ function applyLifecycle(catalog) {
281
281
  // Whether Kairo can actually launch a candidate itself right now, per
282
282
  // adapter — real, current state (execution-adapters/index.js's own
283
283
  // `launchable` flags, intelligence/execution-router.js's checkCandidate),
284
- // not a guess. OpenCode Go stays "manual" until the real empirical
285
- // automatic-execution proof this session's plan calls for
286
- // (`opencode run -m opencode-go/<model>`, verifying real provider
287
- // attribution and Go-only consumption) actually runs and passes see
288
- // this module's own doc. Cursor is "automatic": its own execution
289
- // adapter (execution-adapters/cursor.js) already builds a real,
290
- // auditable non-interactive launch (`cursor-agent -p --output-format
291
- // stream-json`) and parses its structured event stream, the exact same
292
- // shape as Codex/Claude Cursor's own docs explicitly support this
293
- // (headless/CI use is an intended, documented capability, not a hack).
294
- // The earlier "permanent manual" policy predated this adapter being
295
- // finished and had no technical or billing reason behind it (unlike
296
- // OpenCode Go's real, confirmed Go/Zen billing-attribution gap above).
297
- const ACCESS_MODE_BY_ADAPTER = { codex: "automatic", claude: "automatic", cursor: "automatic", "opencode-go": "manual" };
284
+ // not a guess. Cursor is "automatic": its own execution adapter
285
+ // (execution-adapters/cursor.js) already builds a real, auditable
286
+ // non-interactive launch (`cursor-agent -p --output-format stream-json`)
287
+ // and parses its structured event stream, the exact same shape as
288
+ // Codex/Claude — Cursor's own docs explicitly support this (headless/CI
289
+ // use is an intended, documented capability, not a hack).
290
+ //
291
+ // OpenCode Go is "automatic" too now, but with a real, confirmed caveat:
292
+ // a live reproduction (2026-09-18, against a real account) found the
293
+ // `opencode` CLI genuinely hangs with zero output for some real Go
294
+ // models not a stale finding. What makes this safe to flip anyway is
295
+ // the real idle timeout the execution adapter itself now declares
296
+ // (execution-adapters/opencode.js's OPENCODE_IDLE_TIMEOUT_MS,
297
+ // run-supervisor.js resets it on every real chunk) a genuine hang gets
298
+ // converted into a real FAILED run within a bounded, known time instead
299
+ // of hanging forever, which is exactly what the human's own explicit
300
+ // decision requires: try Go, and if it's not OK, automatically move to
301
+ // the next real available model. OpenCode Zen stays "manual" — the
302
+ // real, confirmed Go/Zen billing-attribution gap (see this module's own
303
+ // doc above) is unrelated to this and still unresolved.
304
+ const ACCESS_MODE_BY_ADAPTER = { codex: "automatic", claude: "automatic", cursor: "automatic", "opencode-go": "automatic" };
298
305
 
299
306
  function resolveAccessMode(adapterId, modelId) {
300
307
  // Cursor's own "auto" router picks whichever underlying model it wants
@@ -447,7 +454,7 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
447
454
  * The Automatic Execution Pool: the subset of the Recommendation Pool
448
455
  * Kairo can actually launch itself, right now — real routing's own
449
456
  * candidate source, never QUALITY/EFFICIENT TEAM's. Requires BOTH a real
450
- * accessMode of "automatic" (OpenCode Go is currently manual, and
457
+ * accessMode of "automatic" (OpenCode Zen is currently manual, and
451
458
  * Cursor's own "auto" router model stays manual — see
452
459
  * ModelCandidateIdentity's own doc) AND real,
453
460
  * current eligibility (adapter availability, quota, launchability — the
@@ -89,7 +89,7 @@ export async function readCodexModels({
89
89
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before model list")); });
90
90
 
91
91
  writeRequest(child, 1, "initialize", {
92
- clientInfo: { name: "kairo", title: "Kairo", version: "0.21.0" },
92
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.22.1" },
93
93
  capabilities: {}
94
94
  });
95
95
  });
@@ -151,7 +151,7 @@ export async function readCodexUsage({
151
151
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before rate limits")); });
152
152
 
153
153
  writeRequest(child, 1, "initialize", {
154
- clientInfo: { name: "kairo", title: "Kairo", version: "0.21.0" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.22.1" },
155
155
  capabilities: {}
156
156
  });
157
157
  });
@@ -12,12 +12,20 @@ export function createExecutionAdapter({
12
12
  parseEventLine = null,
13
13
  checkAvailability = null,
14
14
  launchable = null,
15
- preflight = null
15
+ preflight = null,
16
+ idleTimeoutMs = null
16
17
  }) {
17
18
  return {
18
19
  id,
19
20
  label,
20
21
  executable,
22
+ // The real, adapter-declared "no output for this long means genuinely
23
+ // hung, not just working" threshold (run-supervisor.js resets this on
24
+ // every stdout/stderr chunk, so a real long-running task is never
25
+ // killed just for taking a while — only real silence trips it). null
26
+ // (the default) means this adapter is trusted not to hang; only an
27
+ // adapter with a real, observed hanging failure mode declares one.
28
+ idleTimeoutMs,
21
29
  capabilities: {
22
30
  structuredEvents: false,
23
31
  tokens: false,
@@ -12,8 +12,19 @@ export function listExecutionAdapters() {
12
12
  return [...EXECUTION_ADAPTERS];
13
13
  }
14
14
 
15
+ /**
16
+ * Resolves the real adapter object for an id — "opencode-go"/"opencode-zen"
17
+ * both share the single real "opencode" adapter object (one executable,
18
+ * one launch/parse contract; the Go/Zen split is a routing/eligibility
19
+ * distinction, decided by execution-router.js's checkCandidate, never a
20
+ * separate adapter object), the same prefix rule execution-router.js's own
21
+ * findAdapter already uses. Never silently falls through for an unrelated
22
+ * unknown id — only an id that is exactly "opencode" or starts with
23
+ * "opencode-" maps this way.
24
+ */
15
25
  export function resolveExecutionAdapter(id) {
16
- const adapter = EXECUTION_ADAPTERS.find((candidate) => candidate.id === id);
26
+ const baseId = id === "opencode" || id.startsWith("opencode-") ? "opencode" : id;
27
+ const adapter = EXECUTION_ADAPTERS.find((candidate) => candidate.id === baseId);
17
28
  if (!adapter) {
18
29
  throw new Error(`Unknown execution adapter "${id}". Use ${EXECUTION_ADAPTER_IDS.join(", ")}.`);
19
30
  }
@@ -3,6 +3,17 @@ import { isExecutableAvailable } from "../../cli-probe.js";
3
3
 
4
4
  const EXECUTABLE = "opencode";
5
5
 
6
+ // Real, reproduced failure mode (2026-09-18): `opencode run -m
7
+ // opencode-go/<model>` genuinely hangs with ZERO stdout/stderr output for
8
+ // several real models, confirmed live against a real account — not a
9
+ // stale finding. Some other real models fail fast and cleanly instead
10
+ // (e.g. a real 403 region-lock error came back instantly). Since a real,
11
+ // legitimate task can genuinely run for minutes while still producing
12
+ // real output, this is an IDLE timeout (reset on every real chunk, see
13
+ // run-supervisor.js), never an absolute one — only genuine silence this
14
+ // long trips it.
15
+ export const OPENCODE_IDLE_TIMEOUT_MS = 60_000;
16
+
6
17
  // Verified live (`opencode run --format json`) against a real account: the
7
18
  // CLI genuinely emits parseable NDJSON events — step_start/step_finish,
8
19
  // tool_use (with real tool name + status), text, and error — including
@@ -10,19 +21,18 @@ const EXECUTABLE = "opencode";
10
21
  // step_finish. That earlier "does not emit auditable structured events"
11
22
  // claim was wrong; structuredEvents below is now an accurate capability.
12
23
  //
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.
24
+ // `launchable: true` now for Go specifically. This adapter is shared by
25
+ // both OpenCode Go (subscription, $10/mo) and OpenCode Zen (PAYG); the
26
+ // real, unresolved billing-attribution gap between them (no per-event way
27
+ // to prove which tier served a request) is still real, but
28
+ // execution-router.js's checkCandidate refuses "opencode-zen" outright
29
+ // regardless of this flag, so this flag only ever matters for
30
+ // "opencode-go" in practice and for Go, that gap doesn't apply (its
31
+ // own dedicated `/zen/go/*` endpoint is a real, separate gateway, see
32
+ // model-candidate-catalog.js's own doc). The real remaining risk was a
33
+ // live-confirmed hang for some Go models, not billing mitigated by this
34
+ // adapter's own idleTimeoutMs (see above) converting a genuine hang into
35
+ // a bounded real failure instead of an indefinite one.
26
36
  function checkOpencodeAvailability(context = {}) {
27
37
  const available = isExecutableAvailable(EXECUTABLE, { env: context.env ?? process.env });
28
38
  if (!available) {
@@ -32,8 +42,8 @@ function checkOpencodeAvailability(context = {}) {
32
42
  };
33
43
  }
34
44
  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."
45
+ available: true, compatible: true, launchable: true,
46
+ reason: null
37
47
  };
38
48
  }
39
49
 
@@ -100,5 +110,6 @@ export default createExecutionAdapter({
100
110
  },
101
111
  checkAvailability: checkOpencodeAvailability,
102
112
  buildLaunch: buildOpencodeLaunch,
103
- parseEventLine: parseOpencodeEventLine
113
+ parseEventLine: parseOpencodeEventLine,
114
+ idleTimeoutMs: OPENCODE_IDLE_TIMEOUT_MS
104
115
  });
@@ -78,6 +78,13 @@ export async function supervisePreparedRun({
78
78
  }) {
79
79
  const handoff = await consumeRunHandoff(homeDir, runId);
80
80
  const adapter = resolveAdapterImpl(handoff.agentId);
81
+ // A caller-supplied timeoutMs always wins; otherwise the adapter's own
82
+ // declared idleTimeoutMs applies automatically — this is the ONLY path
83
+ // real detached runs (the production `wait: false` case) ever take,
84
+ // since the handoff itself never carries a caller timeoutMs (see
85
+ // run-manager.js's writeRunHandoff), so an adapter-level default is the
86
+ // only way a real detached opencode-go run ever gets one at all.
87
+ const effectiveTimeoutMs = timeoutMs ?? adapter.idleTimeoutMs ?? null;
81
88
  let metadata = await readRunState(homeDir, runId);
82
89
 
83
90
  if (!metadata) {
@@ -310,9 +317,12 @@ export async function supervisePreparedRun({
310
317
 
311
318
  const failed = exitCode !== 0;
312
319
  const nextState = failed ? RUN_STATES.FAILED : RUN_STATES.COMPLETED;
320
+ const failureReason = timedOutByIdle
321
+ ? `No real output for ${effectiveTimeoutMs}ms (idle timeout) — likely hung, not a normal completion`
322
+ : `Process exited with code ${exitCode}`;
313
323
  metadata = transitionRunState(metadata, nextState, {
314
324
  exitCode,
315
- error: failed ? `Process exited with code ${exitCode}` : null
325
+ error: failed ? failureReason : null
316
326
  });
317
327
  await writeRunState(homeDir, metadata);
318
328
  await appendRunEvent(homeDir, createRunEvent({
@@ -341,7 +351,23 @@ export async function supervisePreparedRun({
341
351
  });
342
352
  });
343
353
 
354
+ // An IDLE timeout, never an absolute one: a real, legitimate task can run
355
+ // for many minutes while still producing real output, so the timer is
356
+ // rearmed on every real stdout/stderr chunk (see opencode.js's own
357
+ // idleTimeoutMs doc — this only ever exists to catch genuine silence,
358
+ // like the real hang confirmed there, not to cap a working run's length).
359
+ let timedOutByIdle = false;
360
+ const armIdleTimeout = () => {
361
+ if (effectiveTimeoutMs == null || effectiveTimeoutMs <= 0) return;
362
+ if (timeoutHandle) clearTimeout(timeoutHandle);
363
+ timeoutHandle = setTimeout(() => {
364
+ timedOutByIdle = true;
365
+ child.kill("SIGTERM");
366
+ }, effectiveTimeoutMs);
367
+ };
368
+
344
369
  child.stdout.on("data", (chunk) => {
370
+ armIdleTimeout();
345
371
  enqueue(async () => {
346
372
  stdoutBuffer += chunk.toString();
347
373
  stdoutBuffer = await flushBuffer(stdoutBuffer, "stdout");
@@ -349,17 +375,14 @@ export async function supervisePreparedRun({
349
375
  });
350
376
 
351
377
  child.stderr.on("data", (chunk) => {
378
+ armIdleTimeout();
352
379
  enqueue(async () => {
353
380
  stderrBuffer += chunk.toString();
354
381
  stderrBuffer = await flushBuffer(stderrBuffer, "stderr");
355
382
  });
356
383
  });
357
384
 
358
- if (timeoutMs != null && timeoutMs > 0) {
359
- timeoutHandle = setTimeout(() => {
360
- child.kill("SIGTERM");
361
- }, timeoutMs);
362
- }
385
+ armIdleTimeout();
363
386
 
364
387
  void serializeStateWrite(async () => {
365
388
  metadata = {
@@ -7,6 +7,17 @@ import { writeAtomicJson } from "./write-atomic-json.js";
7
7
 
8
8
  const writeLocks = new Map();
9
9
 
10
+ // OpenCode Go and Zen share a single real execution adapter object (one
11
+ // executable, one launch/parse contract — see
12
+ // execution-adapters/index.js's own resolveExecutionAdapter doc), but
13
+ // they are NOT the same thing for usage/budget tracking: Go is a flat
14
+ // $10/mo subscription with its own weekly/monthly quota, Zen is pay-per-
15
+ // token with its own real balance — genuinely separate budgets that must
16
+ // never share one usage record. So this whitelist is deliberately wider
17
+ // than EXECUTION_ADAPTER_IDS, not a mirror of it.
18
+ const EXTRA_USAGE_PROVIDER_IDS = ["opencode-go", "opencode-zen"];
19
+ const VALID_USAGE_PROVIDER_IDS = [...EXECUTION_ADAPTER_IDS, ...EXTRA_USAGE_PROVIDER_IDS];
20
+
10
21
  export function getUsageDir(homeDir) {
11
22
  return harnessHomePaths(homeDir).usageDir;
12
23
  }
@@ -15,13 +26,12 @@ export function getUsageDir(homeDir) {
15
26
  * The one real boundary a provider id gets validated at before it's ever
16
27
  * used to build a path — the same defense-in-depth role
17
28
  * assertWorktreeId/assertTaskId already play elsewhere. Provider ids are a
18
- * real, closed list (the same one execution-adapters/index.js resolves
19
- * against), never a free-form string, so an unknown one is always a bug
20
- * to surface loudly rather than a path to silently sanitize.
29
+ * real, closed list, never a free-form string, so an unknown one is
30
+ * always a bug to surface loudly rather than a path to silently sanitize.
21
31
  */
22
32
  function usagePath(homeDir, provider) {
23
- if (!EXECUTION_ADAPTER_IDS.includes(provider)) {
24
- throw new Error(`Unknown provider "${provider}" for usage tracking. Use ${EXECUTION_ADAPTER_IDS.join(", ")}.`);
33
+ if (!VALID_USAGE_PROVIDER_IDS.includes(provider)) {
34
+ throw new Error(`Unknown provider "${provider}" for usage tracking. Use ${VALID_USAGE_PROVIDER_IDS.join(", ")}.`);
25
35
  }
26
36
  return join(getUsageDir(homeDir), `${provider}.json`);
27
37
  }
@@ -60,7 +70,7 @@ export async function listProviderUsage(homeDir) {
60
70
  for (const entry of entries) {
61
71
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
62
72
  const provider = entry.name.slice(0, -".json".length);
63
- if (!EXECUTION_ADAPTER_IDS.includes(provider)) continue;
73
+ if (!VALID_USAGE_PROVIDER_IDS.includes(provider)) continue;
64
74
  const record = await readProviderUsage(homeDir, provider);
65
75
  if (record) records.push(record);
66
76
  }