@kal-elsam/kairo-runtime 0.20.0 → 0.22.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.
- package/CHANGELOG.md +42 -0
- package/package.json +1 -1
- package/src/global/cockpit/app.js +10 -0
- package/src/global/conversation/service.js +40 -11
- package/src/global/intelligence/execution-router.js +5 -9
- package/src/global/intelligence/model-candidate-catalog.js +23 -16
- package/src/global/observability/codex-models.js +1 -1
- package/src/global/observability/codex-usage.js +1 -1
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +9 -1
- package/src/global/runtime/execution-adapters/index.js +12 -1
- package/src/global/runtime/execution-adapters/opencode.js +27 -16
- package/src/global/runtime/run-supervisor.js +29 -6
- package/src/global/runtime/usage-store.js +16 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.22.0 — 2026-09-18 (Kairo Runtime)
|
|
9
|
+
|
|
10
|
+
Minor release. Promotes OpenCode Go to a real automatic execution
|
|
11
|
+
provider.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- OpenCode Go's accessMode flips from "manual" to "automatic" — its own
|
|
16
|
+
dedicated `/zen/go/*` gateway is a real, separate endpoint from Zen's,
|
|
17
|
+
resolving the earlier billing-attribution concern for Go specifically.
|
|
18
|
+
OpenCode Zen keeps its own real, unresolved Go/Zen billing-attribution
|
|
19
|
+
gap and stays manual.
|
|
20
|
+
- Adds an adapter-declared idle timeout (60s for OpenCode, reset on
|
|
21
|
+
every real output chunk — never an absolute one, so a real
|
|
22
|
+
long-running task is never killed just for taking a while) as the
|
|
23
|
+
safety net for a live-reproduced CLI hang: a genuine hang now becomes
|
|
24
|
+
a bounded, detectable failure instead of an indefinite one.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Three previously-latent bugs, never exercised since Go was never
|
|
29
|
+
launchable before: `resolveExecutionAdapter` threw for
|
|
30
|
+
"opencode-go"/"opencode-zen"; real task execution launched with the
|
|
31
|
+
catalog's bare model id instead of the real "opencode-go/<id>"
|
|
32
|
+
form the CLI needs to route deterministically; the usage/quota
|
|
33
|
+
tracking store rejected "opencode-go"/"opencode-zen" as valid
|
|
34
|
+
providers despite their genuinely separate budgets.
|
|
35
|
+
|
|
36
|
+
## 0.21.0 — 2026-09-18 (Kairo Runtime)
|
|
37
|
+
|
|
38
|
+
Patch-level polish. When a role resolves to a manual-only provider
|
|
39
|
+
(OpenCode Go), the `planExecution` preview now carries the exact
|
|
40
|
+
real task text an automatic run would use, and the cockpit pushes it
|
|
41
|
+
into the transcript ready to paste into that provider's own chat —
|
|
42
|
+
instead of only naming the assigned model.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `buildExecutionTaskPrompt` (service.js) is the one real formula for a
|
|
47
|
+
task's launch text, shared by `executePlan` (real automatic runs)
|
|
48
|
+
and `planExecution`'s `MANUAL_HANDOFF` preview (`taskPrompt`).
|
|
49
|
+
|
|
8
50
|
## 0.20.0 — 2026-09-18 (Kairo Runtime)
|
|
9
51
|
|
|
10
52
|
Minor release. Promotes Cursor to a real automatic execution provider.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
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",
|
|
@@ -190,6 +190,16 @@ export async function runCockpitApp({
|
|
|
190
190
|
return runAction("Asking PROJECT TEAM who should execute this", async () => {
|
|
191
191
|
const decision = await service.planExecution({ cwd, taskId, role });
|
|
192
192
|
view.showExecuteConfirm(taskId, decision);
|
|
193
|
+
// MANUAL_HANDOFF: Kairo can't launch this itself, so the real
|
|
194
|
+
// task text (the exact same one an automatic run would get —
|
|
195
|
+
// see service.js's buildExecutionTaskPrompt) goes into the
|
|
196
|
+
// transcript, ready to paste into the assigned provider's own
|
|
197
|
+
// chat — never just naming the model and leaving the human to
|
|
198
|
+
// reconstruct the prompt themselves.
|
|
199
|
+
if (decision.decision === "MANUAL_HANDOFF" && decision.taskPrompt) {
|
|
200
|
+
const modelLabel = decision.modelRef?.displayName ?? decision.model ?? "the assigned model";
|
|
201
|
+
pushTranscript("kairo", `${decision.provider} · ${modelLabel} can't be launched automatically — paste this into its chat:\n\n${decision.taskPrompt}`);
|
|
202
|
+
}
|
|
193
203
|
});
|
|
194
204
|
},
|
|
195
205
|
onExecute: (taskId, decision) => {
|
|
@@ -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";
|
|
@@ -367,6 +368,24 @@ export function createConversationService(deps = {}) {
|
|
|
367
368
|
|
|
368
369
|
async function root(cwd) { return resolveRoot(cwd); }
|
|
369
370
|
|
|
371
|
+
/**
|
|
372
|
+
* The exact real task text an automatic run gets launched with (see
|
|
373
|
+
* executePlan) — the ONE real formula, never a second one invented for
|
|
374
|
+
* display purposes. Reused by toExecutionPreview so a MANUAL_HANDOFF
|
|
375
|
+
* preview can hand the human this same text, ready to paste into the
|
|
376
|
+
* manual-only provider's own chat, instead of just naming the model.
|
|
377
|
+
* @param {string} planMarkdown
|
|
378
|
+
*/
|
|
379
|
+
function buildExecutionTaskPrompt(planMarkdown) {
|
|
380
|
+
return [
|
|
381
|
+
"Implement the explicitly approved architecture plan below.",
|
|
382
|
+
"Follow repository AGENTS.md and Gentle governance. Do not treat plan approval as any additional governance receipt.",
|
|
383
|
+
"Use safe, non-bypassed permissions for this session.",
|
|
384
|
+
"",
|
|
385
|
+
planMarkdown
|
|
386
|
+
].join("\n");
|
|
387
|
+
}
|
|
388
|
+
|
|
370
389
|
/**
|
|
371
390
|
* Projects a real project-router decision into the public
|
|
372
391
|
* ProjectExecutionPreview shape — never recalculates anything the
|
|
@@ -380,20 +399,28 @@ export function createConversationService(deps = {}) {
|
|
|
380
399
|
* - Everything else (MANUAL_HANDOFF, or WAIT_FOR_PROJECT_TEAM with no
|
|
381
400
|
* real alternative): null — nothing to confirm into an automatic run.
|
|
382
401
|
* @param {ReturnType<typeof resolveProjectRoute>} route
|
|
402
|
+
* @param {{planMarkdown: string}|null} [record] - present only from
|
|
403
|
+
* planExecution (which already read the real plan record); used to
|
|
404
|
+
* attach `taskPrompt` for a MANUAL_HANDOFF decision only — a ROUTED/
|
|
405
|
+
* WAIT_FOR_PROJECT_TEAM preview never needs it, since executePlan
|
|
406
|
+
* builds the real task text itself from the SAME buildExecutionTaskPrompt.
|
|
383
407
|
*/
|
|
384
|
-
function toExecutionPreview(route) {
|
|
408
|
+
function toExecutionPreview(route, record = null) {
|
|
385
409
|
let confirmationTarget = null;
|
|
386
410
|
if (route.decision === "ROUTED" && route.model) {
|
|
387
411
|
confirmationTarget = { role: route.role, selection: "assigned", strategyFingerprint: route.strategyFingerprint, candidateKey: route.model.candidateKey ?? null };
|
|
388
412
|
} else if (route.decision === "WAIT_FOR_PROJECT_TEAM" && route.suggestedAlternative?.model) {
|
|
389
413
|
confirmationTarget = { role: route.role, selection: "suggested-alternative", strategyFingerprint: route.strategyFingerprint, candidateKey: route.suggestedAlternative.model.candidateKey ?? null };
|
|
390
414
|
}
|
|
415
|
+
const taskPrompt = route.decision === "MANUAL_HANDOFF" && record?.planMarkdown
|
|
416
|
+
? buildExecutionTaskPrompt(record.planMarkdown)
|
|
417
|
+
: null;
|
|
391
418
|
return {
|
|
392
419
|
decision: route.decision, role: route.role,
|
|
393
420
|
provider: route.provider, model: route.model?.modelId ?? null, modelRef: route.model,
|
|
394
421
|
assignmentSource: route.assignmentSource, strategyFingerprint: route.strategyFingerprint, why: route.why,
|
|
395
422
|
blockedAssignment: route.blockedAssignment, suggestedAlternative: route.suggestedAlternative,
|
|
396
|
-
confirmationTarget
|
|
423
|
+
confirmationTarget, taskPrompt
|
|
397
424
|
};
|
|
398
425
|
}
|
|
399
426
|
|
|
@@ -961,7 +988,7 @@ export function createConversationService(deps = {}) {
|
|
|
961
988
|
const record = await readPlan(projectRoot, taskId);
|
|
962
989
|
if (!record) throw new Error(`Plan "${taskId}" not found.`);
|
|
963
990
|
const route = await this.routeProjectExecution(role, projectRoot);
|
|
964
|
-
return { ...toExecutionPreview(route), projectRoot, taskId };
|
|
991
|
+
return { ...toExecutionPreview(route, record), projectRoot, taskId };
|
|
965
992
|
},
|
|
966
993
|
/**
|
|
967
994
|
* @param {object} args
|
|
@@ -998,7 +1025,15 @@ export function createConversationService(deps = {}) {
|
|
|
998
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.`);
|
|
999
1026
|
}
|
|
1000
1027
|
const resolvedAgentId = resolvedCandidate.adapterId;
|
|
1001
|
-
|
|
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;
|
|
1002
1037
|
|
|
1003
1038
|
const runId = newRunId();
|
|
1004
1039
|
const createdAt = new Date().toISOString();
|
|
@@ -1010,13 +1045,7 @@ export function createConversationService(deps = {}) {
|
|
|
1010
1045
|
if (!raced) throw error;
|
|
1011
1046
|
return { ...publicPlan(record, raced), projectRoot, reused: true };
|
|
1012
1047
|
}
|
|
1013
|
-
const task =
|
|
1014
|
-
"Implement the explicitly approved architecture plan below.",
|
|
1015
|
-
"Follow repository AGENTS.md and Gentle governance. Do not treat plan approval as any additional governance receipt.",
|
|
1016
|
-
"Use safe, non-bypassed permissions for this session.",
|
|
1017
|
-
"",
|
|
1018
|
-
record.planMarkdown
|
|
1019
|
-
].join("\n");
|
|
1048
|
+
const task = buildExecutionTaskPrompt(record.planMarkdown);
|
|
1020
1049
|
try {
|
|
1021
1050
|
const started = await launchRun({
|
|
1022
1051
|
homeDir, runId, agentId: resolvedAgentId, task, cwd: projectRoot, model: resolvedModel,
|
|
@@ -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 —
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
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
|
|
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.
|
|
285
|
-
//
|
|
286
|
-
// (`
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
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
|
|
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.
|
|
92
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.22.0" },
|
|
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.
|
|
154
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.22.0" },
|
|
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
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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:
|
|
36
|
-
reason:
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
19
|
-
*
|
|
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 (!
|
|
24
|
-
throw new Error(`Unknown provider "${provider}" for usage tracking. Use ${
|
|
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 (!
|
|
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
|
}
|