@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,194 @@
1
+ import { createRunId } from "./run-types.js";
2
+ import { startRun as startRunDefault } from "./run-manager.js";
3
+ import {
4
+ beginRoleRun as beginRoleRunDefault,
5
+ completeRoleRun as completeRoleRunDefault,
6
+ markInterrupted,
7
+ markReadyForReview as markReadyForReviewDefault
8
+ } from "./execution-worktree-manager.js";
9
+ import { readWorktreeState } from "./execution-worktree-store.js";
10
+ import { WORKTREE_STATES } from "./execution-worktree-types.js";
11
+ import { readTaskRecord } from "../architect/architect-store.js";
12
+ import { readSkillCatalog } from "../intelligence/skill-catalog.js";
13
+ import { matchSkills } from "../intelligence/execution-router.js";
14
+
15
+ /**
16
+ * Strict order — never parallel, never reordered. Debugger's real work
17
+ * only makes sense against Builder's own real commit, and Tester's only
18
+ * against both.
19
+ */
20
+ const ROLE_CHAIN = Object.freeze(["Builder", "Debugger", "Tester"]);
21
+
22
+ const ROLE_INSTRUCTIONS = Object.freeze({
23
+ Builder:
24
+ "You are acting as the Builder role in this execution worktree. Implement the following "
25
+ + "approved plan directly in the current working tree. Only edit real files — Kairo itself "
26
+ + "owns every real commit; never run `git add` or `git commit` yourself.",
27
+ Debugger:
28
+ "You are acting as the Debugger role in this execution worktree. The Builder role's real "
29
+ + "implementation of the following approved plan is already committed in the current working "
30
+ + "tree. Review it for real bugs, edge cases, or deviations from the plan, and fix what you "
31
+ + "find. Only edit real files — Kairo itself owns every real commit; never run `git add` or "
32
+ + "`git commit` yourself.",
33
+ Tester:
34
+ "You are acting as the Tester role in this execution worktree. The Builder and Debugger "
35
+ + "roles' real work on the following approved plan is already committed in the current "
36
+ + "working tree. Write and run real tests that validate this implementation against the plan. "
37
+ + "Only edit real files — Kairo itself owns every real commit; never run `git add` or `git "
38
+ + "commit` yourself."
39
+ });
40
+
41
+ /**
42
+ * Appends the real, project-local skills (docs/skills, .claude/skills, etc
43
+ * — see skill-catalog.js) whose own real description actually overlaps
44
+ * this plan's text — the exact same word-overlap matcher execution-
45
+ * router.js already uses for ASK-mode routing, never a new heuristic.
46
+ * Only the real name + real file path are ever included; the skill's own
47
+ * content is never duplicated into the prompt — the role's real agent run
48
+ * has real filesystem access inside this same worktree and can read the
49
+ * real SKILL.md itself if it decides the match is relevant. An empty
50
+ * catalog or zero matches changes nothing: the plan text is untouched.
51
+ */
52
+ function buildRoleTask(role, planMarkdown, skills = []) {
53
+ const matches = matchSkills(planMarkdown, skills);
54
+ const skillsByName = new Map(skills.map((skill) => [skill.name, skill]));
55
+ const skillLines = matches
56
+ .map((match) => skillsByName.get(match.name))
57
+ .filter(Boolean)
58
+ .map((skill) => `- ${skill.name} (${skill.path}): ${skill.description}`);
59
+
60
+ const skillsSection = skillLines.length > 0
61
+ ? `\n\n## Relevant project skills\nThese real project skills may apply — read the file at its own path if useful, never assume its content:\n${skillLines.join("\n")}`
62
+ : "";
63
+
64
+ return `${ROLE_INSTRUCTIONS[role]}\n\n${planMarkdown}${skillsSection}`;
65
+ }
66
+
67
+ /**
68
+ * Automatically chains Builder -> Debugger -> Tester inside one already-
69
+ * created execution worktree — the real point of everything increments
70
+ * 1-4 built: isolation (worktree), a trustworthy per-role transaction
71
+ * (beginRoleRun/completeRoleRun), and a governed budget
72
+ * (assertProviderNotExhausted, already wired into startRun itself) all
73
+ * had to exist BEFORE this was safe to automate.
74
+ *
75
+ * Only ever drives a freshly-created worktree (real status PENDING, no
76
+ * role has run yet) — this is a strict, from-scratch chain, not a
77
+ * resumable one; a worktree that already progressed manually should keep
78
+ * being driven manually via beginRoleRun/completeRoleRun/
79
+ * markReadyForReview directly.
80
+ *
81
+ * Per role, in strict order: beginRoleRun, a real startRun pointed at the
82
+ * worktree's own treePath (the same governed budget gate from
83
+ * usage-manager.js already runs inside startRun itself — no separate
84
+ * check needed here), then completeRoleRun once the real run reaches a
85
+ * terminal state. The task text sent to each role's real agent run wraps
86
+ * the same real, approved plan.md with a role-specific framing — the
87
+ * plan itself is never split or rewritten, only presented differently.
88
+ *
89
+ * Any real failure — the run itself failing, completeRoleRun rejecting a
90
+ * bad diff, or the run never even starting (a budget/preflight/adapter
91
+ * failure before any real process existed) — stops the chain immediately
92
+ * at that role. A never-started run has no run for completeRoleRun's own
93
+ * checks to find, so this function marks the worktree INTERRUPTED itself
94
+ * in that one case; every other real failure is already handled by
95
+ * completeRoleRun's own existing logic. The next role is never attempted
96
+ * once one fails.
97
+ *
98
+ * Only once all three roles complete does this call markReadyForReview
99
+ * automatically — the one, explicit boundary automation stops at. Preview
100
+ * and apply remain entirely manual, exactly as designed in increment 3:
101
+ * a reviewed, confirmed `git merge --ff-only` is never automatic.
102
+ * @param {object} args
103
+ * @param {string} args.worktreeId
104
+ * @param {string} args.homeDir
105
+ * @param {string} args.agentId - the default execution-adapters/index.js provider for every role
106
+ * @param {Partial<Record<"Builder"|"Debugger"|"Tester", string>>} [args.roleAgents] - per-role provider override
107
+ * @param {string|null} [args.model]
108
+ * @param {object|null} [args.profile] - the resolveProfile() shape ({profile, sources}), passed straight through to startRun
109
+ * @param {string} [args.cliVersion]
110
+ * @param {string[]} [args.permissions]
111
+ * @param {boolean} [args.captureTranscript]
112
+ * @param {Function} [args.spawnImpl] - forwarded to every real startRun call, see run-manager.js's own
113
+ * @param {Function} [args.resolveAdapterImpl] - forwarded to every real startRun call, see run-manager.js's own
114
+ */
115
+ export async function runOrchestratedChain({
116
+ worktreeId,
117
+ homeDir,
118
+ agentId,
119
+ roleAgents = {},
120
+ model = null,
121
+ profile = null,
122
+ cliVersion,
123
+ permissions = [],
124
+ captureTranscript = false,
125
+ spawnImpl,
126
+ resolveAdapterImpl,
127
+ startRunImpl = startRunDefault,
128
+ beginRoleRunImpl = beginRoleRunDefault,
129
+ completeRoleRunImpl = completeRoleRunDefault,
130
+ markReadyForReviewImpl = markReadyForReviewDefault
131
+ }) {
132
+ const initial = await readWorktreeState(homeDir, worktreeId);
133
+ if (!initial) throw new Error(`Execution worktree "${worktreeId}" not found.`);
134
+ if (initial.status !== WORKTREE_STATES.PENDING) {
135
+ throw new Error(
136
+ `Execution worktree "${worktreeId}" is ${initial.status}; runOrchestratedChain only drives a `
137
+ + "freshly-created worktree from PENDING — a worktree already in progress must be driven manually."
138
+ );
139
+ }
140
+
141
+ const record = await readTaskRecord(initial.projectRoot, initial.taskId);
142
+ if (!record?.planMarkdown) {
143
+ throw new Error(`Execution worktree "${worktreeId}" has no real approved plan text to orchestrate roles from.`);
144
+ }
145
+
146
+ // Read once, from the worktree's own real checkout — exactly what each
147
+ // role's real agent run will itself see, not the main project's
148
+ // possibly-different working tree.
149
+ const skills = await readSkillCatalog(initial.treePath);
150
+
151
+ const completedRoles = [];
152
+
153
+ for (const role of ROLE_CHAIN) {
154
+ const runId = createRunId();
155
+ await beginRoleRunImpl({ worktreeId, role, runId, homeDir });
156
+
157
+ try {
158
+ const { completion } = await startRunImpl({
159
+ homeDir,
160
+ runId,
161
+ agentId: roleAgents[role] ?? agentId,
162
+ task: buildRoleTask(role, record.planMarkdown, skills),
163
+ cwd: initial.treePath,
164
+ model,
165
+ permissions,
166
+ captureTranscript,
167
+ cliVersion,
168
+ profile,
169
+ wait: true,
170
+ ...(spawnImpl ? { spawnImpl } : {}),
171
+ ...(resolveAdapterImpl ? { resolveAdapterImpl } : {})
172
+ });
173
+ await completion;
174
+ } catch (error) {
175
+ const worktreeNow = (await readWorktreeState(homeDir, worktreeId)) ?? initial;
176
+ await markInterrupted(
177
+ homeDir, worktreeNow,
178
+ `Role "${role}" run "${runId}" could not be started: ${error.message ?? String(error)}`
179
+ );
180
+ throw new Error(`Orchestrated chain stopped at role "${role}": ${error.message ?? String(error)}`);
181
+ }
182
+
183
+ try {
184
+ await completeRoleRunImpl({ worktreeId, role, runId, homeDir });
185
+ } catch (error) {
186
+ throw new Error(`Orchestrated chain stopped at role "${role}": ${error.message ?? String(error)}`);
187
+ }
188
+
189
+ completedRoles.push(role);
190
+ }
191
+
192
+ const ready = await markReadyForReviewImpl({ worktreeId, homeDir });
193
+ return { worktree: ready, completedRoles };
194
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync } from "node:fs";
2
+ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
3
+ import { harnessHomePaths, worktreePaths } from "../paths.js";
4
+ import { writeAtomicJson } from "./write-atomic-json.js";
5
+
6
+ const writeLocks = new Map();
7
+
8
+ export function getWorktreesDir(homeDir) {
9
+ return harnessHomePaths(homeDir).worktreesDir;
10
+ }
11
+
12
+ /** Creates the initial, real PENDING record — exclusive so two concurrent creations can never collide on the same worktreeId. */
13
+ export async function createWorktreeRecord(homeDir, metadata) {
14
+ const { worktreeDir, statePath } = worktreePaths(homeDir, metadata.worktreeId);
15
+ await mkdir(worktreeDir, { recursive: true });
16
+ await writeAtomicJson(statePath, metadata, { createExclusive: true });
17
+ return metadata;
18
+ }
19
+
20
+ export async function readWorktreeState(homeDir, worktreeId) {
21
+ const { statePath } = worktreePaths(homeDir, worktreeId);
22
+ if (!existsSync(statePath)) return null;
23
+
24
+ try {
25
+ return JSON.parse(await readFile(statePath, "utf8"));
26
+ } catch (error) {
27
+ throw new Error(`Invalid execution worktree state at ${statePath}: ${error.message}`);
28
+ }
29
+ }
30
+
31
+ /** Read-modify-write is serialized per worktreeId — same real race protection run-store.js's writeRunState already relies on. */
32
+ export async function writeWorktreeState(homeDir, metadata) {
33
+ const key = metadata.worktreeId;
34
+ const previous = writeLocks.get(key) ?? Promise.resolve();
35
+ const next = previous.then(async () => {
36
+ const { worktreeDir, statePath } = worktreePaths(homeDir, metadata.worktreeId);
37
+ await mkdir(worktreeDir, { recursive: true });
38
+ await writeAtomicJson(statePath, metadata);
39
+ return metadata;
40
+ });
41
+ writeLocks.set(key, next.catch(() => {}));
42
+ return next;
43
+ }
44
+
45
+ /** Append-only, one real checkpoint per line — mirrors run-store.js's own appendRunEvent for events.jsonl. */
46
+ export async function appendCheckpoint(homeDir, worktreeId, checkpoint) {
47
+ const { worktreeDir, checkpointsPath } = worktreePaths(homeDir, worktreeId);
48
+ await mkdir(worktreeDir, { recursive: true });
49
+ await appendFile(checkpointsPath, `${JSON.stringify(checkpoint)}\n`, "utf8");
50
+ return checkpoint;
51
+ }
52
+
53
+ export async function readCheckpoints(homeDir, worktreeId) {
54
+ const { checkpointsPath } = worktreePaths(homeDir, worktreeId);
55
+ if (!existsSync(checkpointsPath)) return [];
56
+
57
+ const content = await readFile(checkpointsPath, "utf8");
58
+ return content
59
+ .split("\n")
60
+ .filter((line) => line.trim().length > 0)
61
+ .map((line, index) => {
62
+ try {
63
+ return JSON.parse(line);
64
+ } catch (error) {
65
+ return { parseError: true, line: index + 1, message: error.message };
66
+ }
67
+ });
68
+ }
69
+
70
+ export async function listWorktreeRecords(homeDir) {
71
+ const worktreesDir = getWorktreesDir(homeDir);
72
+ if (!existsSync(worktreesDir)) return [];
73
+
74
+ const entries = await readdir(worktreesDir, { withFileTypes: true });
75
+ const worktrees = [];
76
+ for (const entry of entries) {
77
+ if (!entry.isDirectory()) continue;
78
+ const state = await readWorktreeState(homeDir, entry.name);
79
+ if (state) worktrees.push(state);
80
+ }
81
+ worktrees.sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)));
82
+ return worktrees;
83
+ }
@@ -0,0 +1,45 @@
1
+ export const EXECUTION_WORKTREE_SCHEMA = "kairo.execution-worktree/v1";
2
+
3
+ // pending: registered, git worktree add not yet confirmed.
4
+ // active: at least one role has a real run in progress inside the worktree.
5
+ // ready_for_review: all roles finished; a real preview/merge is possible.
6
+ // applying: a confirmed merge is in flight against the real project.
7
+ // applied / discarded: terminal, successful/abandoned outcomes.
8
+ // interrupted: Kairo restarted mid-operation — see recovery, never guessed.
9
+ export const WORKTREE_STATES = Object.freeze({
10
+ PENDING: "pending",
11
+ ACTIVE: "active",
12
+ READY_FOR_REVIEW: "ready_for_review",
13
+ APPLYING: "applying",
14
+ APPLIED: "applied",
15
+ DISCARDED: "discarded",
16
+ INTERRUPTED: "interrupted"
17
+ });
18
+
19
+ export const ACTIVE_WORKTREE_STATES = new Set([
20
+ WORKTREE_STATES.PENDING,
21
+ WORKTREE_STATES.ACTIVE,
22
+ WORKTREE_STATES.READY_FOR_REVIEW,
23
+ WORKTREE_STATES.APPLYING
24
+ ]);
25
+
26
+ export function isActiveWorktreeState(state) {
27
+ return ACTIVE_WORKTREE_STATES.has(state);
28
+ }
29
+
30
+ export const TERMINAL_WORKTREE_STATES = new Set([
31
+ WORKTREE_STATES.APPLIED,
32
+ WORKTREE_STATES.DISCARDED,
33
+ WORKTREE_STATES.INTERRUPTED
34
+ ]);
35
+
36
+ export function isTerminalWorktreeState(state) {
37
+ return TERMINAL_WORKTREE_STATES.has(state);
38
+ }
39
+
40
+ /** Same shape as run-types.js's own createRunId — an opaque, sortable-enough, collision-resistant local id, never a real git ref name. */
41
+ export function createWorktreeId() {
42
+ const timestamp = Date.now().toString(36);
43
+ const random = Math.random().toString(36).slice(2, 8);
44
+ return `wt_${timestamp}_${random}`;
45
+ }
@@ -131,6 +131,44 @@ export function applyEventToMetadata(metadata, event) {
131
131
  return next;
132
132
  }
133
133
 
134
+ /**
135
+ * Best-effort real human-readable text out of a real "run.transcript"
136
+ * event's own `data` — the shape varies by adapter (Codex/Claude each pass
137
+ * their own CLI's raw JSON event through mostly as-is, see each adapter's
138
+ * own parseEventLine), so this tries the common real shapes in order
139
+ * (flat string, `text`, `content` as a string or an Anthropic-style
140
+ * content-block array, a nested `message`, `result`) and only ever falls
141
+ * back to a raw JSON dump when none of those match — it never silently
142
+ * drops a real event's content just because its exact shape wasn't
143
+ * anticipated.
144
+ * @param {unknown} data
145
+ * @returns {string}
146
+ */
147
+ export function formatTranscriptEventText(data) {
148
+ if (data == null) return "";
149
+ if (typeof data === "string") return data;
150
+ if (typeof data !== "object") return String(data);
151
+ if (typeof data.text === "string") return data.text;
152
+ if (typeof data.content === "string") return data.content;
153
+ if (Array.isArray(data.content)) {
154
+ const text = data.content
155
+ .filter((block) => block && typeof block === "object" && typeof block.text === "string")
156
+ .map((block) => block.text)
157
+ .join("");
158
+ if (text) return text;
159
+ }
160
+ if (data.message && typeof data.message === "object") {
161
+ const nested = formatTranscriptEventText(data.message);
162
+ if (nested) return nested;
163
+ }
164
+ if (typeof data.result === "string") return data.result;
165
+ try {
166
+ return JSON.stringify(data);
167
+ } catch {
168
+ return String(data);
169
+ }
170
+ }
171
+
134
172
  export function transitionRunState(metadata, nextState, { exitCode = null, error = null } = {}) {
135
173
  const now = new Date().toISOString();
136
174
  return {
@@ -11,6 +11,7 @@ import {
11
11
  writeRunState
12
12
  } from "./run-store.js";
13
13
  import { resolveExecutionAdapter } from "./execution-adapters/index.js";
14
+ import { assertProviderNotExhausted } from "./usage-manager.js";
14
15
  import { resolveProfileAgents } from "../profile.js";
15
16
  import { resolveRuntimeOptions } from "./run-profile.js";
16
17
  import { authorizeRunPermissions } from "./run-permissions.js";
@@ -100,10 +101,12 @@ async function prepareRun({
100
101
  captureTranscript = false,
101
102
  cliVersion,
102
103
  profile = null,
103
- strategy = "direct"
104
+ strategy = "direct",
105
+ runId: requestedRunId = null,
106
+ resolveAdapterImpl = resolveExecutionAdapter
104
107
  }) {
105
108
  const normalizedStrategy = assertOrchestratedAgent(agentId, strategy);
106
- const adapter = resolveExecutionAdapter(agentId);
109
+ const adapter = resolveAdapterImpl(agentId);
107
110
  const availability = adapter.availability({ cwd });
108
111
 
109
112
  if (!availability.available) {
@@ -117,6 +120,14 @@ async function prepareRun({
117
120
  );
118
121
  }
119
122
 
123
+ await adapter.preflight({ cwd });
124
+ // The one real, enforced budget gate this increment adds — refuses only
125
+ // a provider whose real, cumulative consumption already reached its own
126
+ // configured budget (usage-manager.js). No configured
127
+ // profile.providerTokenBudgets.<agentId> means no gate: this never
128
+ // blocks a provider nobody set a budget for.
129
+ await assertProviderNotExhausted({ homeDir, provider: agentId, profile: profile?.profile ?? null });
130
+
120
131
  const authorized = authorizeRunPermissions({
121
132
  permissions,
122
133
  agentId,
@@ -129,7 +140,7 @@ async function prepareRun({
129
140
  await assertManagedMinionExtension(homeDir);
130
141
  }
131
142
 
132
- const runId = createRunId();
143
+ const runId = requestedRunId ?? createRunId();
133
144
  const lineage = createRootRunLineage(runId);
134
145
  const metadata = createRunMetadata({
135
146
  runId,
@@ -197,7 +208,9 @@ export async function startRun({
197
208
  timeoutMs = null,
198
209
  wait = true,
199
210
  spawnImpl = spawn,
200
- forkDetachedSupervisorImpl = forkDetachedSupervisor
211
+ forkDetachedSupervisorImpl = forkDetachedSupervisor,
212
+ runId: requestedRunId = null,
213
+ resolveAdapterImpl = resolveExecutionAdapter
201
214
  }) {
202
215
  const { runId, metadata } = await prepareRun({
203
216
  homeDir,
@@ -212,7 +225,9 @@ export async function startRun({
212
225
  captureTranscript,
213
226
  cliVersion,
214
227
  profile,
215
- strategy: normalizeRunStrategy(strategy)
228
+ strategy: normalizeRunStrategy(strategy),
229
+ runId: requestedRunId,
230
+ resolveAdapterImpl
216
231
  });
217
232
 
218
233
  if (!wait) {
@@ -274,7 +289,8 @@ export async function startRun({
274
289
  timeoutMs,
275
290
  spawnImpl,
276
291
  cancelledRuns,
277
- activeProcesses
292
+ activeProcesses,
293
+ resolveAdapterImpl
278
294
  });
279
295
 
280
296
  return {
@@ -14,6 +14,7 @@ import {
14
14
  writeRunState
15
15
  } from "./run-store.js";
16
16
  import { resolveExecutionAdapter } from "./execution-adapters/index.js";
17
+ import { recordProviderUsage } from "./usage-manager.js";
17
18
  import { consumeRunHandoff } from "./run-handoff.js";
18
19
  import { isRunCancelRequested } from "./run-cancel-signal.js";
19
20
  import { readSupervisorLock, touchSupervisorLock, writeSupervisorLock } from "./run-supervisor-lock.js";
@@ -72,10 +73,11 @@ export async function supervisePreparedRun({
72
73
  timeoutMs = null,
73
74
  spawnImpl = spawn,
74
75
  cancelledRuns = null,
75
- activeProcesses = null
76
+ activeProcesses = null,
77
+ resolveAdapterImpl = resolveExecutionAdapter
76
78
  }) {
77
79
  const handoff = await consumeRunHandoff(homeDir, runId);
78
- const adapter = resolveExecutionAdapter(handoff.agentId);
80
+ const adapter = resolveAdapterImpl(handoff.agentId);
79
81
  let metadata = await readRunState(homeDir, runId);
80
82
 
81
83
  if (!metadata) {
@@ -85,16 +87,6 @@ export async function supervisePreparedRun({
85
87
  const captureTranscript = handoff.captureTranscript === true;
86
88
  const strategy = normalizeRunStrategy(handoff.strategy ?? metadata.strategy ?? "direct");
87
89
  const extensionPath = resolveOrchestratedExtensionPath(homeDir, strategy);
88
- const launch = adapter.buildLaunch({
89
- task: handoff.task,
90
- cwd: handoff.cwd,
91
- model: handoff.model,
92
- permissions: handoff.permissions ?? [],
93
- profile: handoff.profile ?? null,
94
- strategy,
95
- extensionPath
96
- });
97
-
98
90
  metadata = {
99
91
  ...metadata,
100
92
  state: RUN_STATES.RUNNING,
@@ -109,6 +101,26 @@ export async function supervisePreparedRun({
109
101
  lastHeartbeat: new Date().toISOString()
110
102
  });
111
103
 
104
+ try {
105
+ await adapter.preflight({ cwd: handoff.cwd });
106
+ } catch (error) {
107
+ metadata = transitionRunState(metadata, RUN_STATES.FAILED, { error: error.message });
108
+ await writeRunState(homeDir, metadata);
109
+ await appendRunEvent(homeDir, createRunEvent({
110
+ runId, type: "run.failed", data: { error: error.message }
111
+ }), { captureTranscript: shouldPersistTranscript(captureTranscript) });
112
+ throw error;
113
+ }
114
+ const launch = adapter.buildLaunch({
115
+ task: handoff.task,
116
+ cwd: handoff.cwd,
117
+ model: handoff.model,
118
+ permissions: handoff.permissions ?? [],
119
+ profile: handoff.profile ?? null,
120
+ strategy,
121
+ extensionPath
122
+ });
123
+
112
124
  const child = spawnImpl(launch.command, launch.args, {
113
125
  cwd: launch.cwd,
114
126
  env: buildOrchestratedRuntimeEnv({
@@ -169,6 +181,13 @@ export async function supervisePreparedRun({
169
181
  captureTranscript: true
170
182
  }),
171
183
  runId,
184
+ // createRunEvent defaults source to "kairo" — the real event this
185
+ // is mirrored from already carries the real adapter id (see
186
+ // normalizeAdapterEvent's own `source: adapterId`), so this must
187
+ // be explicitly propagated, or every provider's transcript would
188
+ // collapse to a generic "kairo" label with no way to tell whether
189
+ // a given line came from Codex, Claude, or another real provider.
190
+ source: event.source ?? adapter.id,
172
191
  timestamp: event.timestamp
173
192
  });
174
193
  }
@@ -301,6 +320,16 @@ export async function supervisePreparedRun({
301
320
  type: failed ? "run.failed" : "run.completed",
302
321
  data: { exitCode }
303
322
  }), { captureTranscript: shouldPersistTranscript(captureTranscript) });
323
+ // Recorded exactly once, here, at the real terminal state — never
324
+ // on intermediate streamed usage events, which would double-count
325
+ // as they arrive. A run whose adapter never reported real usage
326
+ // (tokenUsage.total stays null) records nothing.
327
+ await recordProviderUsage(homeDir, adapter.id, {
328
+ input: metadata.tokenUsage?.input,
329
+ output: metadata.tokenUsage?.output,
330
+ total: metadata.tokenUsage?.total,
331
+ cost: metadata.cost
332
+ });
304
333
  if (!failed && strategy === RUN_STRATEGIES.ORCHESTRATED) {
305
334
  await finalizeOrchState(runId, { homeDir, recovered: false });
306
335
  }
@@ -0,0 +1,96 @@
1
+ import { classifyUsageTier, createProviderUsageRecord, isProviderExhausted } from "./usage-types.js";
2
+ import { readProviderUsage, writeProviderUsage } from "./usage-store.js";
3
+
4
+ /**
5
+ * Records one real run's own usage onto a provider's real, cumulative
6
+ * total — called exactly once per real run, at the moment it reaches a
7
+ * terminal state (run-supervisor.js), never on intermediate streamed
8
+ * usage events (which would double-count as they arrive). A run with no
9
+ * real usage data at all (tokens null — the adapter never reported any,
10
+ * or the process never really started) records nothing: there is no
11
+ * real consumption to attribute, and bumping runCount for it would be
12
+ * noise, not evidence.
13
+ * @param {string} homeDir
14
+ * @param {string} provider - a real execution-adapters/index.js id
15
+ * @param {{input?: number|null, output?: number|null, total?: number|null, cost?: number|null}} usage
16
+ */
17
+ export async function recordProviderUsage(homeDir, provider, usage = {}) {
18
+ const total = Number.isFinite(usage.total) ? usage.total : null;
19
+ if (total == null) return null;
20
+
21
+ const current = (await readProviderUsage(homeDir, provider)) ?? createProviderUsageRecord(provider);
22
+ const input = Number.isFinite(usage.input) ? usage.input : 0;
23
+ const output = Number.isFinite(usage.output) ? usage.output : 0;
24
+ const cost = Number.isFinite(usage.cost) ? usage.cost : 0;
25
+
26
+ const next = {
27
+ ...current,
28
+ totalInputTokens: current.totalInputTokens + input,
29
+ totalOutputTokens: current.totalOutputTokens + output,
30
+ totalTokens: current.totalTokens + total,
31
+ totalCost: current.totalCost + cost,
32
+ runCount: current.runCount + 1,
33
+ updatedAt: new Date().toISOString()
34
+ };
35
+
36
+ await writeProviderUsage(homeDir, provider, next);
37
+ return next;
38
+ }
39
+
40
+ function resolveProviderBudget(profile, provider) {
41
+ const perProvider = profile?.providerTokenBudgets;
42
+ if (perProvider && typeof perProvider === "object" && Number.isFinite(perProvider[provider])) {
43
+ return perProvider[provider];
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * The real, current classification for one provider — real cumulative
50
+ * consumption (readProviderUsage) against the real configured budget for
51
+ * that provider (profile.providerTokenBudgets), never a single run's own
52
+ * usage. No configured budget for this provider means no real consumption
53
+ * record is even required to answer HEALTHY.
54
+ * @param {object} args
55
+ * @param {string} args.homeDir
56
+ * @param {string} args.provider
57
+ * @param {object} [args.profile] - a resolved profile object (profile.js's own `profile` field)
58
+ */
59
+ export async function getProviderUsageState({ homeDir, provider, profile = null }) {
60
+ const budgetTokens = resolveProviderBudget(profile, provider);
61
+ const record = (await readProviderUsage(homeDir, provider)) ?? createProviderUsageRecord(provider);
62
+ const tier = classifyUsageTier(record.totalTokens, budgetTokens);
63
+
64
+ return {
65
+ provider,
66
+ budgetTokens,
67
+ consumedTokens: record.totalTokens,
68
+ consumedCost: record.totalCost,
69
+ runCount: record.runCount,
70
+ tier
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The real, single enforcement point this increment adds — called from
76
+ * run-manager.js's prepareRun, right after adapter.preflight and before a
77
+ * new run is ever created. Refuses only a provider whose real cumulative
78
+ * consumption has reached its own configured budget; every other tier
79
+ * (HEALTHY through CRITICAL) is informational only in this increment —
80
+ * there is no automatic model-chaining yet for a softer tier to throttle.
81
+ * @param {object} args
82
+ * @param {string} args.homeDir
83
+ * @param {string} args.provider
84
+ * @param {object} [args.profile]
85
+ */
86
+ export async function assertProviderNotExhausted({ homeDir, provider, profile = null }) {
87
+ const state = await getProviderUsageState({ homeDir, provider, profile });
88
+ if (isProviderExhausted(state.tier)) {
89
+ throw new Error(
90
+ `Provider "${provider}" has exhausted its configured token budget `
91
+ + `(${state.consumedTokens}/${state.budgetTokens} tokens). Raise providerTokenBudgets.${provider} `
92
+ + "in the profile, or wait for the budget to be reset, before starting a new run."
93
+ );
94
+ }
95
+ return state;
96
+ }