@gleapai/kai-bridge 0.2.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.
@@ -0,0 +1,1122 @@
1
+ // Shared runner contract for the in-sandbox agent drivers.
2
+ //
3
+ // Every runner (`opencode-runner.mjs`, `claude-runner.mjs`,
4
+ // `codex-runner.mjs`) parses the SAME argv flag set, emits the SAME
5
+ // ParsedAgentOutput JSONL on stdout (one JSON object per line), and
6
+ // uses the SAME stderr trace/heartbeat conventions. This module is
7
+ // the single source of truth for those mechanics so the engines stay
8
+ // interchangeable behind `AgentRunner` on the host.
9
+ //
10
+ // Hard rules every runner must follow:
11
+ // - stdout carries ONLY contract JSONL. The host pipeline's idle
12
+ // watchdog resets exclusively on stdout data; during long silent
13
+ // model stretches a runner MUST keep stdout alive (see
14
+ // `startIdleKeepalive` / throttled `{type:"keepalive"}` emits).
15
+ // - stderr is diagnostics only (`[<prefix>-trace]` lines). It never
16
+ // resets the watchdog.
17
+ // - The final event of a successful turn is `{type:"result", ...}`
18
+ // carrying sessionId + usage; the process then exits 0.
19
+ //
20
+ // Zero npm dependencies — Node stdlib only.
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { existsSync, readdirSync, statSync, writeSync } from "node:fs";
24
+ import { basename, join } from "node:path";
25
+
26
+ // ── Argv parsing (no minimist; keep zero deps inside the sandbox) ─────
27
+ export function parseArgv(rawArgs) {
28
+ const out = {};
29
+ for (let i = 0; i < rawArgs.length; i += 1) {
30
+ const a = rawArgs[i];
31
+ if (!a.startsWith("--")) continue;
32
+ const key = a.slice(2);
33
+ const next = rawArgs[i + 1];
34
+ if (next == null || next.startsWith("--")) {
35
+ out[key] = true;
36
+ } else {
37
+ out[key] = next;
38
+ i += 1;
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export function decodeB64(value) {
45
+ if (value == null || value === true) return "";
46
+ return Buffer.from(String(value), "base64").toString("utf8");
47
+ }
48
+
49
+ export function decodeB64Json(value, fallback) {
50
+ if (value == null || value === true) return fallback;
51
+ try {
52
+ return JSON.parse(decodeB64(value));
53
+ } catch {
54
+ return fallback;
55
+ }
56
+ }
57
+
58
+ // ── Kai agent names ───────────────────────────────────────────────────
59
+ // Plan mode runs `kai-planner` (read-only strategic planner); build
60
+ // mode runs `kai` (orchestrator / coder). Legacy short aliases
61
+ // (`plan` / `build` / `prometheus` / `sisyphus` / `kai-builder` / the
62
+ // old long descriptive names) resolve forward so a session paused on
63
+ // an older template resumes cleanly on a newer one.
64
+ export const KAI_PLANNER_AGENT_NAME = "kai-planner";
65
+ export const KAI_BUILDER_AGENT_NAME = "kai";
66
+ export const KAI_RESOLUTION_ANALYST_AGENT_NAME = "kai-resolution-analyst";
67
+
68
+ export function normalizeAgentName(raw) {
69
+ const value = typeof raw === "string" ? raw : "";
70
+ const normalised = value.toLowerCase();
71
+ if (
72
+ normalised === "plan" ||
73
+ normalised === "kai-planner" ||
74
+ normalised === "prometheus" ||
75
+ normalised === "prometheus - plan builder"
76
+ )
77
+ return KAI_PLANNER_AGENT_NAME;
78
+ if (
79
+ normalised === "build" ||
80
+ normalised === "kai" ||
81
+ normalised === "kai-builder" ||
82
+ normalised === "sisyphus" ||
83
+ normalised === "sisyphus - ultraworker"
84
+ )
85
+ return KAI_BUILDER_AGENT_NAME;
86
+ return value || KAI_BUILDER_AGENT_NAME;
87
+ }
88
+
89
+ export function isPlanModeAgent(agentName) {
90
+ // The resolution analyst used to run here (native plan mode = read-only).
91
+ // It now has FULL tool access (it may act, not just recommend), so it
92
+ // runs as an artifact writer (dontAsk) instead — see
93
+ // ARTIFACT_WRITER_AGENT_NAMES below. Plan mode would route every
94
+ // write/mutation tool to a canUseTool approver that doesn't exist in a
95
+ // headless run, so under plan mode it could never call mutation tools.
96
+ return agentName === KAI_PLANNER_AGENT_NAME;
97
+ }
98
+
99
+ // Artifact-writer agents produce ONLY `.kai/` outputs at the
100
+ // workspace root (findings dossiers, answer.md, research.md) — never
101
+ // repository edits, never a PR. Each engine scopes their writes as
102
+ // tightly as it can natively (claude: dontAsk + `Write(.kai/**)`
103
+ // allow rules; codex: workspaceWrite can't subtract cwd, so prompt
104
+ // discipline); `revertRepoMutations` below is the engine-agnostic
105
+ // backstop that guarantees the repo clones stay pristine either way.
106
+ export const ARTIFACT_WRITER_AGENT_NAMES = [
107
+ "kai-documentarian",
108
+ "kai-asker",
109
+ "kai-researcher",
110
+ // Full tool access (incl. non-destructive mutations) + writes its
111
+ // report to `.kai/resolution-analysis.md`. dontAsk hard-denies the
112
+ // destructive verbs (server-side guard) and anything not allow-listed,
113
+ // with no permission prompt to hang the headless run.
114
+ KAI_RESOLUTION_ANALYST_AGENT_NAME,
115
+ ];
116
+
117
+ export function isArtifactWriterAgent(agentName) {
118
+ return ARTIFACT_WRITER_AGENT_NAMES.includes(agentName);
119
+ }
120
+
121
+ function findWorkspaceRepos(workDir) {
122
+ const repos = [];
123
+ if (existsSync(join(workDir, ".git"))) {
124
+ repos.push({ name: basename(workDir) || workDir, path: workDir });
125
+ return repos;
126
+ }
127
+
128
+ let entries = [];
129
+ try {
130
+ entries = readdirSync(workDir);
131
+ } catch {
132
+ return repos;
133
+ }
134
+ for (const entry of entries) {
135
+ const repoDir = join(workDir, entry);
136
+ try {
137
+ if (!statSync(repoDir).isDirectory()) continue;
138
+ if (!existsSync(join(repoDir, ".git"))) continue;
139
+ } catch {
140
+ continue;
141
+ }
142
+ repos.push({ name: entry, path: repoDir });
143
+ }
144
+ return repos;
145
+ }
146
+
147
+ function parseIgnoredPaths(output) {
148
+ return String(output || "")
149
+ .split("\0")
150
+ .filter((entry) => entry.startsWith("!! "))
151
+ .map((entry) => entry.slice(3))
152
+ .filter(Boolean);
153
+ }
154
+
155
+ function readIgnoredPaths(repoDir) {
156
+ const result = spawnSync(
157
+ "git",
158
+ [
159
+ "-C",
160
+ repoDir,
161
+ "status",
162
+ "--porcelain=v1",
163
+ "-z",
164
+ "--ignored=matching",
165
+ "--untracked-files=normal",
166
+ ],
167
+ { encoding: "utf8", timeout: 30_000 },
168
+ );
169
+ if (result.error || result.status !== 0) {
170
+ const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`;
171
+ throw new Error(`git status --ignored failed for ${repoDir}: ${detail}`);
172
+ }
173
+ return parseIgnoredPaths(result.stdout);
174
+ }
175
+
176
+ function runGitOrThrow(repoDir, args, timeout = 60_000) {
177
+ const result = spawnSync("git", ["-C", repoDir, ...args], {
178
+ encoding: "utf8",
179
+ timeout,
180
+ });
181
+ if (result.error || result.status !== 0) {
182
+ const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`;
183
+ throw new Error(`git ${args.join(" ")} failed for ${repoDir}: ${detail}`);
184
+ }
185
+ return result.stdout || "";
186
+ }
187
+
188
+ const PRESERVED_ARTIFACT_PATHS = [".kai", ".codex-kai", ".opencode"];
189
+
190
+ function isPreservedArtifactPath(path) {
191
+ const normalized = String(path || "").replace(/\/$/, "");
192
+ return PRESERVED_ARTIFACT_PATHS.some(
193
+ (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Snapshot repository HEADs before an artifact writer starts. A plain
199
+ * `git status` cleanup cannot see an agent-created commit, so without this
200
+ * baseline a `git add && git commit` shell command could make a mutation
201
+ * look clean and survive the safety net.
202
+ */
203
+ export function captureRepoBaselines(workDir) {
204
+ return findWorkspaceRepos(workDir).flatMap((repo) => {
205
+ const head = spawnSync("git", ["-C", repo.path, "rev-parse", "HEAD"], {
206
+ encoding: "utf8",
207
+ timeout: 30_000,
208
+ });
209
+ if (head.status !== 0 || !head.stdout?.trim()) return [];
210
+ const branch = spawnSync(
211
+ "git",
212
+ ["-C", repo.path, "symbolic-ref", "--quiet", "--short", "HEAD"],
213
+ { encoding: "utf8", timeout: 30_000 },
214
+ );
215
+ return [
216
+ {
217
+ ...repo,
218
+ head: head.stdout.trim(),
219
+ branch: branch.status === 0 ? branch.stdout.trim() || null : null,
220
+ // Record ignored roots too. `git clean -fd` intentionally skips them,
221
+ // so without a baseline an artifact writer could leave a newly created
222
+ // node_modules/ or build cache behind while status looks clean. Git's
223
+ // `--ignored=matching` reports the top-level matching entry; contents
224
+ // of an already-present ignored directory are preserved as local cache
225
+ // state and are not content-diffed or copied/restored here.
226
+ ignoredPaths: readIgnoredPaths(repo.path),
227
+ },
228
+ ];
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Revert any mutation inside the git repositories under `workDir`
234
+ * (tracked changes restored, untracked files removed — `.kai/`
235
+ * subtrees excepted). The artifact agents read the repos and write
236
+ * `.kai/` at the WORKSPACE root, which sits outside every repo clone,
237
+ * so a clean revert of the clones never touches their output.
238
+ *
239
+ * Why this exists even with engine-side permission scoping: claude's
240
+ * read-only Bash allowlist is prefix-matched, so an allowed verb with
241
+ * a shell redirection (`cat x > y`) can still write; codex's
242
+ * workspace-write sandbox always keeps the whole cwd writable
243
+ * (writableRoots is additive-only — verified against codex-rs
244
+ * source). This backstop makes stray writes non-durable regardless of
245
+ * which engine ran the turn. Mirrors the opencode runner's plan-mode
246
+ * safety net.
247
+ *
248
+ * Returns the list of repos that had to be reverted (empty = clean).
249
+ */
250
+ export function revertRepoMutations(workDir, baselines = null) {
251
+ const reverted = [];
252
+ // The coder pipeline keeps artifact writers at the workspace root, with
253
+ // repository clones as children. Still handle a repository passed as
254
+ // workDir directly: older warm sandboxes and standalone runner invocations
255
+ // may use that layout. Prefer the pre-turn baseline when supplied so a
256
+ // committed mutation (which has a clean status) is still discoverable.
257
+ const repos = Array.isArray(baselines) && baselines.length > 0
258
+ ? baselines
259
+ : findWorkspaceRepos(workDir);
260
+
261
+ for (const repo of repos) {
262
+ const repoDir = repo.path;
263
+ const dirty = runGitOrThrow(repoDir, ["status", "--porcelain"], 30_000).trim();
264
+ const currentHead = spawnSync(
265
+ "git",
266
+ ["-C", repoDir, "rev-parse", "HEAD"],
267
+ { encoding: "utf8", timeout: 30_000 },
268
+ );
269
+ if (typeof repo.head === "string" && (currentHead.error || currentHead.status !== 0)) {
270
+ const detail =
271
+ currentHead.error?.message || currentHead.stderr?.trim() || `exit ${currentHead.status}`;
272
+ throw new Error(`git rev-parse HEAD failed for ${repoDir}: ${detail}`);
273
+ }
274
+ const headChanged =
275
+ typeof repo.head === "string" &&
276
+ currentHead.status === 0 &&
277
+ currentHead.stdout.trim() !== repo.head;
278
+ const ignoredBaseline = Array.isArray(repo.ignoredPaths)
279
+ ? new Set(repo.ignoredPaths)
280
+ : null;
281
+ const newIgnoredPaths = ignoredBaseline
282
+ ? readIgnoredPaths(repoDir).filter(
283
+ (path) => !ignoredBaseline.has(path) && !isPreservedArtifactPath(path),
284
+ )
285
+ : [];
286
+ if (!dirty && !headChanged && newIgnoredPaths.length === 0) continue;
287
+ traceLog("repo.revert", {
288
+ repo: repo.name,
289
+ files: [
290
+ ...dirty.split("\n").filter(Boolean),
291
+ ...newIgnoredPaths.map((path) => `!! ${path}`),
292
+ ].slice(0, 10),
293
+ headChanged,
294
+ });
295
+ if (typeof repo.head === "string") {
296
+ // Restore the original checkout as well as its commit. This covers an
297
+ // agent switching/renaming branches before committing. The fallback
298
+ // recreates a deleted original branch at the captured commit.
299
+ if (repo.branch) {
300
+ try {
301
+ runGitOrThrow(repoDir, ["checkout", "-f", repo.branch]);
302
+ } catch {
303
+ runGitOrThrow(repoDir, ["checkout", "-B", repo.branch, repo.head]);
304
+ }
305
+ } else {
306
+ runGitOrThrow(repoDir, ["checkout", "--detach", "-f", repo.head]);
307
+ }
308
+ runGitOrThrow(repoDir, ["reset", "--hard", repo.head]);
309
+ }
310
+ // Restore tracked files, then sweep untracked ones — keeping any
311
+ // repo-nested `.kai/` content a persona may legitimately have
312
+ // produced.
313
+ runGitOrThrow(repoDir, ["checkout", "--", "."]);
314
+ runGitOrThrow(repoDir, ["clean", "-fd", "-e", ".kai"]);
315
+ for (const path of newIgnoredPaths) {
316
+ runGitOrThrow(repoDir, ["clean", "-fdX", "--", path]);
317
+ }
318
+
319
+ if (ignoredBaseline) {
320
+ const residualIgnored = readIgnoredPaths(repoDir).filter(
321
+ (path) => !ignoredBaseline.has(path) && !isPreservedArtifactPath(path),
322
+ );
323
+ if (residualIgnored.length > 0) {
324
+ throw new Error(
325
+ `artifact cleanup left ignored paths in ${repoDir}: ${residualIgnored.join(", ")}`,
326
+ );
327
+ }
328
+ }
329
+ reverted.push(repo.name);
330
+ }
331
+ return reverted;
332
+ }
333
+
334
+ // Multi-modal attachments — only image/* and application/pdf survive;
335
+ // anything else is dropped so prompt bodies stay valid on every engine.
336
+ const SUPPORTED_FILE_PART_MIME_PREFIXES = ["image/"];
337
+ const SUPPORTED_FILE_PART_MIMES = new Set(["application/pdf"]);
338
+
339
+ // LEGACY: steps are no longer enforced as a turn limit — budget
340
+ // (--max-budget-usd) is the sole cap (budget-only decision, 2026-06-18).
341
+ // Retained only so `--max-steps` parsing stays backward-compatible
342
+ // (Runner Contract v1); runners parse the value but ignore it for
343
+ // enforcement. Safe to remove once no caller passes --max-steps.
344
+ export const DEFAULT_STEP_CAP = 50;
345
+
346
+ /**
347
+ * Decode the full runner argv contract into one options object. All
348
+ * flags are optional except `--task-b64`; callers fail fast on an
349
+ * empty `task` themselves (the error message differs per runner).
350
+ *
351
+ * --agent <name> kai | kai-planner | kai-documentarian | …
352
+ * --model <provider/modelID> canonical registry id
353
+ * --engine-model <slug> engine-native model slug (claude/codex wire form)
354
+ * --subagent-model <provider/modelID> cheaper sibling for explorer subagents
355
+ * --effort <tier> low|medium|high|extra_high|max
356
+ * --session-id <id> resume an existing engine session/thread
357
+ * --max-budget-usd <number> abort when accumulated cost exceeds
358
+ * --max-steps <number> LEGACY/no-op — steps no longer abort a
359
+ * turn; budget (--max-budget-usd) is the
360
+ * sole limit. Still parsed for compat.
361
+ * --task-b64 <base64> REQUIRED — the user prompt
362
+ * --feedback-b64 <base64> wrapped follow-up prompt for resumes
363
+ * --answers-b64 <base64-json> string[][] question replies
364
+ * --attachments-b64 <base64-json> [{ name, url, type }]
365
+ * --system-prompt-b64 <base64> project-level system-prompt addendum
366
+ * --mcp-config-b64 <base64-json> [{ id, name, transport, command?, args?, env?, url?, headers?, disabledTools? }]
367
+ * --model-pricing-b64 <base64-json> { "<canonical model id>": { input, cachedInput, cacheWriteInput, output } }
368
+ * USD per 1M tokens — lets runners price
369
+ * usage for budget enforcement + costUsd
370
+ * when the engine reports tokens only.
371
+ * --provider-orders-b64 <base64-json> { "<engine slug>": ["provider-slug", …] }
372
+ * OpenRouter provider pins — the runner
373
+ * injects provider.order into the body.
374
+ * --work-dir <path> working directory for the agent
375
+ */
376
+ export function parseRunnerArgs(rawArgs) {
377
+ const argv = parseArgv(rawArgs);
378
+
379
+ const agent = normalizeAgentName(argv.agent);
380
+
381
+ const maxSteps = (() => {
382
+ const raw = argv["max-steps"];
383
+ if (raw == null || raw === true) return DEFAULT_STEP_CAP;
384
+ const n = Number(raw);
385
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_STEP_CAP;
386
+ })();
387
+
388
+ const maxBudgetUsd =
389
+ argv["max-budget-usd"] != null && argv["max-budget-usd"] !== true
390
+ ? Number(argv["max-budget-usd"])
391
+ : undefined;
392
+
393
+ const questionAnswers = (() => {
394
+ const parsed = decodeB64Json(argv["answers-b64"], null);
395
+ if (!Array.isArray(parsed)) return null;
396
+ return parsed.map((row) => (Array.isArray(row) ? row.map(String) : []));
397
+ })();
398
+
399
+ const attachments = (() => {
400
+ const parsed = decodeB64Json(argv["attachments-b64"], []);
401
+ if (!Array.isArray(parsed)) return [];
402
+ return parsed.filter((a) => {
403
+ if (!a || typeof a !== "object") return false;
404
+ const url = typeof a.url === "string" ? a.url : "";
405
+ const type = typeof a.type === "string" ? a.type : "";
406
+ if (!url || !type) return false;
407
+ if (SUPPORTED_FILE_PART_MIMES.has(type)) return true;
408
+ return SUPPORTED_FILE_PART_MIME_PREFIXES.some((p) => type.startsWith(p));
409
+ });
410
+ })();
411
+
412
+ const mcpServers = (() => {
413
+ const parsed = decodeB64Json(argv["mcp-config-b64"], []);
414
+ return Array.isArray(parsed) ? parsed : [];
415
+ })();
416
+
417
+ const modelPricing = (() => {
418
+ const parsed = decodeB64Json(argv["model-pricing-b64"], null);
419
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
420
+ return {};
421
+ const out = {};
422
+ for (const [model, rates] of Object.entries(parsed)) {
423
+ if (!rates || typeof rates !== "object") continue;
424
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
425
+ out[model] = {
426
+ input: num(rates.input),
427
+ output: num(rates.output),
428
+ // Cache rates stay undefined when the registry doesn't define
429
+ // them — the tracker then falls back to the input rate. An
430
+ // EXPLICIT 0 means "free" and must survive the round-trip.
431
+ ...(rates.cachedInput != null ? { cachedInput: num(rates.cachedInput) } : {}),
432
+ ...(rates.cacheWriteInput != null
433
+ ? { cacheWriteInput: num(rates.cacheWriteInput) }
434
+ : {}),
435
+ // Long-context dual-tier fields — drive per-step tier pricing
436
+ // and the tier-split modelBreakdown in createUsageTracker.
437
+ ...(rates.longContextThreshold != null
438
+ ? { longContextThreshold: num(rates.longContextThreshold) }
439
+ : {}),
440
+ ...(rates.longInputMultiplier != null
441
+ ? { longInputMultiplier: num(rates.longInputMultiplier) }
442
+ : {}),
443
+ ...(rates.longOutputMultiplier != null
444
+ ? { longOutputMultiplier: num(rates.longOutputMultiplier) }
445
+ : {}),
446
+ };
447
+ }
448
+ return out;
449
+ })();
450
+
451
+ // Per-model OpenRouter provider pins, keyed by ENGINE slug. The runner
452
+ // injects `provider:{order,allow_fallbacks:true}` into the request body for
453
+ // models present here; absent models keep OpenRouter's default routing.
454
+ const providerOrders = (() => {
455
+ const parsed = decodeB64Json(argv["provider-orders-b64"], null);
456
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
457
+ const out = {};
458
+ for (const [model, order] of Object.entries(parsed)) {
459
+ if (!Array.isArray(order)) continue;
460
+ const slugs = order.filter((p) => typeof p === "string" && p.length > 0);
461
+ if (slugs.length > 0) out[model] = slugs;
462
+ }
463
+ return out;
464
+ })();
465
+
466
+ // Positive-integer token ceilings. Claude Code recognises models by ID
467
+ // PATTERN; a gateway slug like `z-ai/glm-5.3` matches nothing, so it falls
468
+ // back to a 200K assumed context window (it compacts there) and a 32K
469
+ // output cap. The Server sends the registry's real numbers so a 1M-context
470
+ // model stops behaving like a 200K one.
471
+ const positiveInt = (raw) => {
472
+ if (raw == null || raw === true) return undefined;
473
+ const n = Number(raw);
474
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
475
+ };
476
+
477
+ return {
478
+ argv,
479
+ agent,
480
+ isPlanMode: isPlanModeAgent(agent),
481
+ model: argv.model ? String(argv.model) : undefined,
482
+ engineModel: argv["engine-model"] ? String(argv["engine-model"]) : undefined,
483
+ subagentModel: argv["subagent-model"]
484
+ ? String(argv["subagent-model"])
485
+ : undefined,
486
+ effort: argv.effort ? String(argv.effort) : undefined,
487
+ sessionId: argv["session-id"] ? String(argv["session-id"]) : undefined,
488
+ maxBudgetUsd,
489
+ maxSteps,
490
+ task: decodeB64(argv["task-b64"]),
491
+ feedback: decodeB64(argv["feedback-b64"]),
492
+ questionAnswers,
493
+ attachments,
494
+ customSystemPrompt: decodeB64(argv["system-prompt-b64"]),
495
+ mcpServers,
496
+ modelPricing,
497
+ providerOrders,
498
+ maxContextTokens: positiveInt(argv["max-context-tokens"]),
499
+ maxOutputTokens: positiveInt(argv["max-output-tokens"]),
500
+ backgroundModel: argv["background-model"]
501
+ ? String(argv["background-model"])
502
+ : undefined,
503
+ workDir: argv["work-dir"] ? String(argv["work-dir"]) : process.cwd(),
504
+ };
505
+ }
506
+
507
+ // ── Stdout emission ───────────────────────────────────────────────────
508
+ // `process.stdout.write` is async on pipes — Node buffers writes and
509
+ // flushes opportunistically, so a long-running agent can produce
510
+ // stdout the e2b parent only sees in big bursts. `writeSync` to fd=1
511
+ // bypasses the stream's buffer and pushes each JSONL line straight to
512
+ // the OS, so events reach the pipeline as the agent produces them.
513
+ let lastEmitAt = Date.now();
514
+
515
+ export function emit(obj) {
516
+ try {
517
+ writeSync(1, JSON.stringify(obj) + "\n");
518
+ lastEmitAt = Date.now();
519
+ } catch {
520
+ // Parent pipe closed — nothing useful to do.
521
+ process.exit(1);
522
+ }
523
+ }
524
+
525
+ // Same path as `emit`, but never exits — for fail-fast call sites that
526
+ // are already on their way out and must not recurse into exit logic.
527
+ export function emitSync(obj) {
528
+ try {
529
+ writeSync(1, JSON.stringify(obj) + "\n");
530
+ lastEmitAt = Date.now();
531
+ } catch {
532
+ /* parent pipe closed; ignore */
533
+ }
534
+ }
535
+
536
+ export function getLastEmitAt() {
537
+ return lastEmitAt;
538
+ }
539
+
540
+ // ── Liveness keepalive (host idle-watchdog) ───────────────────────────
541
+ // The host's idle-kill watchdog (pipeline.ts) only resets on stdout
542
+ // data and kills after 5 min of silence. A long reasoning step
543
+ // produces no stdout and looks identical to a hang. Runners that
544
+ // stream engine events through a child process should call
545
+ // `startIdleKeepalive()` once: every `intervalMs` with no other emit,
546
+ // it writes a tiny `{type:"keepalive"}` line (the host parser drops
547
+ // it — it never reaches the UI). Runners that can hook token deltas
548
+ // directly (OpenCode) keep their own delta-throttled variant instead
549
+ // so a genuinely hung engine still trips the watchdog.
550
+ export const KEEPALIVE_THROTTLE_MS = 20_000;
551
+
552
+ export function startIdleKeepalive(intervalMs = KEEPALIVE_THROTTLE_MS) {
553
+ const timer = setInterval(() => {
554
+ if (Date.now() - lastEmitAt < intervalMs) return;
555
+ emitSync({ type: "keepalive" });
556
+ }, Math.max(1000, Math.floor(intervalMs / 2)));
557
+ timer.unref();
558
+ return () => clearInterval(timer);
559
+ }
560
+
561
+ // ── Stderr diagnostics ────────────────────────────────────────────────
562
+ // `traceLog` is the always-on event trace: every line lands in the
563
+ // analyzer host's stderr (forwarded to the dev terminal verbatim).
564
+ // `debugLog` is gated on KAI_RUNNER_DEBUG / OPENCODE_RUNNER_DEBUG for
565
+ // verbose payload dumps. Keep trace entries to one short line; the
566
+ // payload is clamped hard so an oversized message never floods the
567
+ // parent terminal.
568
+ let tracePrefix = "runner";
569
+ let lastTraceAt = Date.now();
570
+
571
+ export function setTracePrefix(prefix) {
572
+ tracePrefix = String(prefix || "runner");
573
+ }
574
+
575
+ export function debugLog(prefix, obj) {
576
+ if (!process.env.KAI_RUNNER_DEBUG && !process.env.OPENCODE_RUNNER_DEBUG)
577
+ return;
578
+ try {
579
+ process.stderr.write(
580
+ `[${tracePrefix}-runner] ${prefix} ${JSON.stringify(obj).slice(0, 800)}\n`,
581
+ );
582
+ } catch {
583
+ /* ignore */
584
+ }
585
+ }
586
+
587
+ export function traceLog(prefix, obj) {
588
+ try {
589
+ const ts = new Date().toISOString();
590
+ const body = obj == null ? "" : ` ${JSON.stringify(obj).slice(0, 280)}`;
591
+ process.stderr.write(`[${tracePrefix}-trace] ${ts} ${prefix}${body}\n`);
592
+ lastTraceAt = Date.now();
593
+ } catch {
594
+ /* ignore */
595
+ }
596
+ }
597
+
598
+ export function getLastTraceAt() {
599
+ return lastTraceAt;
600
+ }
601
+
602
+ // Heartbeat — every 30s, if no event has been traced in the last 15s,
603
+ // dump a short status line so a wedged turn is obvious in the log
604
+ // instead of looking like a frozen tab. `getInFlight` returns labels
605
+ // for tools still running so the operator can see WHICH call the
606
+ // agent is waiting on.
607
+ export const HEARTBEAT_MS = 30_000;
608
+ export const HEARTBEAT_QUIET_THRESHOLD_MS = 15_000;
609
+
610
+ export function startHeartbeat(getInFlight) {
611
+ const timer = setInterval(() => {
612
+ const idleMs = Date.now() - lastTraceAt;
613
+ if (idleMs < HEARTBEAT_QUIET_THRESHOLD_MS) return;
614
+ let inFlight = [];
615
+ try {
616
+ inFlight = getInFlight ? getInFlight() : [];
617
+ } catch {
618
+ /* state may not be initialised yet */
619
+ }
620
+ try {
621
+ process.stderr.write(
622
+ `[${tracePrefix}-trace] ${new Date().toISOString()} heartbeat idle=${Math.round(idleMs / 1000)}s in-flight=[${inFlight.join(", ")}]\n`,
623
+ );
624
+ } catch {
625
+ /* ignore */
626
+ }
627
+ }, HEARTBEAT_MS);
628
+ timer.unref();
629
+ return () => clearInterval(timer);
630
+ }
631
+
632
+ // ── Provider/model parsing ────────────────────────────────────────────
633
+ // Accepts the `provider/modelID` slug form coming from the analyzer.
634
+ // Multi-segment modelIDs (e.g. `vendor/model-name` for OpenRouter)
635
+ // join everything after the first `/` back into the modelID.
636
+ export function parseModelSlug(slug) {
637
+ const parts = String(slug).split("/");
638
+ if (parts.length < 2) {
639
+ return { providerID: "anthropic", modelID: slug };
640
+ }
641
+ return { providerID: parts[0], modelID: parts.slice(1).join("/") };
642
+ }
643
+
644
+ // ── Tool input → one-line summary ─────────────────────────────────────
645
+ export const TOOL_SUMMARY_KEY = {
646
+ read: ["filePath", "file_path", "path"],
647
+ write: ["filePath", "file_path", "path"],
648
+ edit: ["filePath", "file_path", "path"],
649
+ multiedit: ["filePath", "file_path", "path"],
650
+ bash: ["command"],
651
+ glob: ["pattern"],
652
+ grep: ["pattern"],
653
+ search: ["pattern", "query"],
654
+ webfetch: ["url"],
655
+ fetch: ["url"],
656
+ websearch: ["query"],
657
+ task: ["description", "prompt"],
658
+ agent: ["description", "prompt"],
659
+ };
660
+
661
+ export function describeBashCommand(command) {
662
+ const cmd = String(command || "")
663
+ .trim()
664
+ .replace(/\s+/g, " ");
665
+ if (!cmd) return "";
666
+ const lower = cmd.toLowerCase();
667
+ const first = cmd.split(/\s+/)[0] || "";
668
+ if (lower === "git status" || lower.startsWith("git status "))
669
+ return "Show working tree status";
670
+ if (lower === "git diff" || lower.startsWith("git diff "))
671
+ return "Show code differences";
672
+ if (lower === "git log" || lower.startsWith("git log "))
673
+ return "Show recent commits";
674
+ if (lower === "git show" || lower.startsWith("git show "))
675
+ return "Show git object details";
676
+ if (lower === "git branch --show-current") return "Show current branch";
677
+ if (lower.startsWith("git add ")) return "Stage selected files";
678
+ if (lower.startsWith("git commit ")) return "Create git commit";
679
+ if (lower.startsWith("git push ")) return "Push git branch";
680
+ if (lower === "npm install" || lower.startsWith("npm install "))
681
+ return "Install package dependencies";
682
+ if (lower === "npm test" || lower.startsWith("npm test "))
683
+ return "Run package tests";
684
+ if (lower === "npm run build" || lower.startsWith("npm run build "))
685
+ return "Build the project";
686
+ const npmRun = cmd.match(/^npm run ([^\s]+)/i);
687
+ if (npmRun) return `Run npm script ${npmRun[1]}`;
688
+ if (lower === "pnpm install" || lower.startsWith("pnpm install "))
689
+ return "Install package dependencies";
690
+ if (lower === "pnpm test" || lower.startsWith("pnpm test "))
691
+ return "Run package tests";
692
+ const pnpmRun = cmd.match(/^pnpm (?:run )?([^\s]+)/i);
693
+ if (
694
+ pnpmRun &&
695
+ !["install", "add", "remove"].includes(pnpmRun[1].toLowerCase())
696
+ ) {
697
+ return `Run pnpm script ${pnpmRun[1]}`;
698
+ }
699
+ if (lower === "yarn install" || lower.startsWith("yarn install "))
700
+ return "Install package dependencies";
701
+ const yarnRun = cmd.match(/^yarn (?:run )?([^\s]+)/i);
702
+ if (
703
+ yarnRun &&
704
+ !["install", "add", "remove"].includes(yarnRun[1].toLowerCase())
705
+ ) {
706
+ return `Run yarn script ${yarnRun[1]}`;
707
+ }
708
+ if (lower.startsWith("node --check ")) return "Check JavaScript syntax";
709
+ if (lower === "tsc" || lower.startsWith("tsc "))
710
+ return "Run TypeScript compiler";
711
+ if (lower.startsWith("npx tsc") || lower.startsWith("pnpm tsc"))
712
+ return "Run TypeScript compiler";
713
+ if (first === "rg") return "Search files with ripgrep";
714
+ if (first === "grep") return "Search file contents";
715
+ if (first === "find") return "Find files";
716
+ if (first === "ls") return "List files";
717
+ if (first === "cat") return "Read file contents";
718
+ if (first === "head") return "Read file beginning";
719
+ if (first === "tail") return "Read file ending";
720
+ if (first === "curl") return "Fetch URL";
721
+ if (first === "gh") return "Run GitHub CLI command";
722
+ const truncated = cmd.length > 96 ? cmd.slice(0, 93).trimEnd() + "…" : cmd;
723
+ return `Run shell command: ${truncated}`;
724
+ }
725
+
726
+ export function summarizeTool(name, input) {
727
+ if (!input || typeof input !== "object") return "";
728
+ if (
729
+ String(name || "").toLowerCase() === "bash" &&
730
+ typeof input.command === "string"
731
+ ) {
732
+ return describeBashCommand(input.command);
733
+ }
734
+ const keys = TOOL_SUMMARY_KEY[String(name || "").toLowerCase()] || [];
735
+ for (const k of keys) {
736
+ const v = input[k];
737
+ if (typeof v === "string" && v.length > 0) {
738
+ return v.length > 160 ? v.slice(0, 157) + "…" : v;
739
+ }
740
+ }
741
+ return "";
742
+ }
743
+
744
+ // Normalise raw tool names so engine variations (`_grep`,
745
+ // `webfetch_v2`, `filesystem.read`, `apply_patch`, etc.) still map to
746
+ // the canonical title-cased name the frontend's TOOL_VERBS /
747
+ // beautifyToolName tables expect. Project MCP tools
748
+ // (`mcp__server__tool`) are passed through untouched — the frontend
749
+ // has its own beautifier for those.
750
+ export function canonicalToolName(name) {
751
+ const raw = String(name || "");
752
+ if (/exit[_-]?plan[_-]?mode$/i.test(raw)) return "ExitPlanMode";
753
+ // The runner-injected `kai_todos` bridge (todo-mcp.mjs) IS TodoWrite:
754
+ // fold every harness's spelling (mcp__kai_todos__todo_write, Codex's
755
+ // server-scoped variant, …) onto the canonical name so the mapper's
756
+ // TodoWrite branch turns the calls into dashboard `todos` events.
757
+ if (/(^|__|\.)todo_write$/.test(raw)) return "TodoWrite";
758
+ if (raw.startsWith("mcp__")) return raw;
759
+ let lower = raw.toLowerCase().replace(/^_+/, "");
760
+ lower = lower.replace(/_v\d+$/, "");
761
+ if (lower.startsWith("filesystem."))
762
+ lower = lower.slice("filesystem.".length);
763
+ const aliases = {
764
+ read: "Read",
765
+ write: "Write",
766
+ edit: "Edit",
767
+ multiedit: "MultiEdit",
768
+ bash: "Bash",
769
+ glob: "Glob",
770
+ grep: "Grep",
771
+ search: "Grep",
772
+ list: "Glob",
773
+ ls: "Glob",
774
+ webfetch: "WebFetch",
775
+ fetch: "WebFetch",
776
+ websearch: "WebSearch",
777
+ task: "Task",
778
+ agent: "Task",
779
+ todowrite: "TodoWrite",
780
+ todos: "TodoWrite",
781
+ exitplanmode: "ExitPlanMode",
782
+ patch: "Edit",
783
+ apply_patch: "Edit",
784
+ askuserquestion: "Question",
785
+ question: "Question",
786
+ };
787
+ if (aliases[lower]) return aliases[lower];
788
+ debugLog("canonical.miss", { raw, lower });
789
+ return raw;
790
+ }
791
+
792
+ // Map raw engine tool status strings to a small canonical set
793
+ // (`running | completed | error | pending`). Different engines use
794
+ // different verbs; collapse them so emit logic doesn't have to.
795
+ export function normalizeToolStatus(raw) {
796
+ const s = String(raw || "").toLowerCase();
797
+ if (s === "completed" || s === "complete" || s === "done") return "completed";
798
+ if (s === "error" || s === "failed") return "error";
799
+ if (s === "running" || s === "in_progress" || s === "inprogress")
800
+ return "running";
801
+ return "pending";
802
+ }
803
+
804
+ export function truncateString(value, max) {
805
+ if (typeof value !== "string") return value;
806
+ return value.length > max ? value.slice(0, max - 1).trimEnd() + "…" : value;
807
+ }
808
+
809
+ export function sanitizeToolValue(value, maxStringLength = 4000) {
810
+ if (typeof value === "string") return truncateString(value, maxStringLength);
811
+ if (Array.isArray(value))
812
+ return value
813
+ .slice(0, 50)
814
+ .map((item) => sanitizeToolValue(item, maxStringLength));
815
+ if (!value || typeof value !== "object") return value;
816
+ const out = {};
817
+ for (const [key, nested] of Object.entries(value)) {
818
+ out[key] = sanitizeToolValue(nested, maxStringLength);
819
+ }
820
+ return out;
821
+ }
822
+
823
+ // ── Tool-schema compatibility (provider quirks) ───────────────────────
824
+ // Some gateways validate the JSON Schema of EVERY tool in a request and
825
+ // reject the whole request when one uses a regex construct their engine
826
+ // lacks. xAI is the known case: probed against grok-4.6 on 2026-08-14,
827
+ // its own error strings name exactly three unsupported constructs —
828
+ // Unicode property escapes \p{...} \P{...}
829
+ // backreferences \1 \k<name>
830
+ // word boundaries \b \B
831
+ // while lookaround, named groups, non-capturing groups and POSIX
832
+ // classes are all accepted. So this is a narrow list, not a blanket
833
+ // "no regex".
834
+ //
835
+ // These are legal ECMA-262 patterns that Anthropic accepts, so they
836
+ // reach the wire untouched from third-party MCP servers we don't
837
+ // control. Deliberately loose matching: a false positive costs one
838
+ // dropped refinement, a miss costs the whole session.
839
+ export const UNSUPPORTED_SCHEMA_PATTERN = /\\[pP]\{|\\k<|\\[1-9]|\\[bB]/;
840
+
841
+ function stripUnsupportedPatterns(node, dropped) {
842
+ if (Array.isArray(node)) {
843
+ for (const child of node) stripUnsupportedPatterns(child, dropped);
844
+ return;
845
+ }
846
+ if (!node || typeof node !== "object") return;
847
+ // Only a string `pattern` is the JSON-Schema keyword; a property
848
+ // legitimately NAMED "pattern" holds a schema object and is skipped
849
+ // here, then visited by the recursion below.
850
+ if (
851
+ typeof node.pattern === "string" &&
852
+ UNSUPPORTED_SCHEMA_PATTERN.test(node.pattern)
853
+ ) {
854
+ dropped.push(node.pattern);
855
+ delete node.pattern;
856
+ }
857
+ for (const key of Object.keys(node)) {
858
+ stripUnsupportedPatterns(node[key], dropped);
859
+ }
860
+ }
861
+
862
+ /**
863
+ * Removes `pattern` keywords a provider's schema validator rejects from
864
+ * every tool in an Anthropic-shaped request body, MUTATING it in place.
865
+ * Walks nested `properties` / `items` / `$defs` / `anyOf` / `oneOf` /
866
+ * `allOf` alike. Returns the dropped patterns, or null when the body
867
+ * needed no change (so callers can skip re-serializing).
868
+ *
869
+ * Dropping is the correct repair, not rewriting: `pattern` is a
870
+ * validation refinement, so removing it only WIDENS what the schema
871
+ * accepts and can never turn a call the model would have made into an
872
+ * invalid one. Rewriting `\p{L}` → `[A-Za-z]` was rejected — it
873
+ * silently stops accepting the non-ASCII input the author allowed.
874
+ */
875
+ export function stripUnsupportedToolPatterns(body) {
876
+ if (!Array.isArray(body?.tools)) return null;
877
+ const dropped = [];
878
+ for (const tool of body.tools) {
879
+ stripUnsupportedPatterns(tool?.input_schema, dropped);
880
+ stripUnsupportedPatterns(tool?.custom?.input_schema, dropped);
881
+ }
882
+ return dropped.length > 0 ? dropped : null;
883
+ }
884
+
885
+ // ── Usage accumulation + pricing ──────────────────────────────────────
886
+ // Shared by the claude / codex runners (OpenCode keeps its own
887
+ // accumulator — its cost events arrive pre-priced from the SDK).
888
+ // Tracks per-model token usage, prices it from the host-forwarded
889
+ // `modelPricing` map (USD per 1M tokens, registry rates), and builds
890
+ // the `result` fields the host billing layer consumes. Model keys
891
+ // MUST be canonical registry ids — callers normalize engine-reported
892
+ // ids before recording.
893
+ export function createUsageTracker(modelPricing = {}) {
894
+ // Buckets are keyed by (model, long-context tier) — one model can hold
895
+ // two buckets when the pricing carries a `longContextThreshold`, so the
896
+ // reported breakdown preserves per-tier attribution. Entries store the
897
+ // PLAIN model string; the composite key is internal only.
898
+ const perModel = new Map();
899
+ let providerCostUsd = 0;
900
+ // Running USD priced per RECORDED step (not recomputed from bucket
901
+ // sums): the long-context surcharge is per-request, so pricing summed
902
+ // buckets would tier-flip once the sum crosses the threshold even
903
+ // though no single request did.
904
+ let pricedUsd = 0;
905
+ // Last model request's context occupancy — a SNAPSHOT (overwritten on
906
+ // each note, last one wins), never a sum. Kept separate from record():
907
+ // seedFromBreakdown() and aggregate residuals also flow through
908
+ // record() and must not move the snapshot.
909
+ let lastContext = null;
910
+
911
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
912
+
913
+ const bucket = (model, longContext) => {
914
+ const name = String(model || "unknown");
915
+ const key = longContext ? `${name}long` : name;
916
+ let entry = perModel.get(key);
917
+ if (!entry) {
918
+ entry = {
919
+ model: name,
920
+ longContext,
921
+ inputTokens: 0,
922
+ cachedInputTokens: 0,
923
+ cacheWriteInputTokens: 0,
924
+ outputTokens: 0,
925
+ turns: 0,
926
+ };
927
+ perModel.set(key, entry);
928
+ }
929
+ return entry;
930
+ };
931
+
932
+ return {
933
+ /**
934
+ * Record one model step's usage. `cachedInputTokens` /
935
+ * `cacheWriteInputTokens` are subsets of the total input side —
936
+ * callers pass whatever the engine reports; `inputTokens` here is
937
+ * the NON-cached fresh input count (pass the engine's
938
+ * `input_tokens` for Anthropic, which already excludes cache
939
+ * reads/writes).
940
+ *
941
+ * `aggregate: true` marks a recording that is a TOTAL over many
942
+ * requests (CLI terminal envelopes, turn summaries) — the
943
+ * long-context tier is a per-request surcharge and cannot be
944
+ * inferred from a sum, so aggregates always land in (and price at)
945
+ * the base tier. Never over-charge from an aggregate.
946
+ *
947
+ * `longContext` (boolean) overrides the inference entirely — used
948
+ * when re-seeding buckets whose tier was already decided per step.
949
+ */
950
+ record(model, usage, { countTurn = true, aggregate = false, longContext: forcedTier } = {}) {
951
+ const fresh = num(usage.inputTokens);
952
+ const cached = num(usage.cachedInputTokens);
953
+ const cacheWrite = num(usage.cacheWriteInputTokens);
954
+ const output = num(usage.outputTokens);
955
+
956
+ const rates = modelPricing[String(model || "unknown")];
957
+ // Tier check uses the step's TOTAL input (fresh + cache buckets) —
958
+ // matches the Server's convention where `input` includes cache.
959
+ const totalInput = fresh + cached + cacheWrite;
960
+ const longContext =
961
+ forcedTier != null
962
+ ? Boolean(forcedTier)
963
+ : !aggregate &&
964
+ rates?.longContextThreshold != null &&
965
+ totalInput > rates.longContextThreshold;
966
+
967
+ if (rates) {
968
+ const inputMult = longContext ? (rates.longInputMultiplier ?? 1) : 1;
969
+ const outputMult = longContext ? (rates.longOutputMultiplier ?? 1) : 1;
970
+ pricedUsd +=
971
+ (fresh / 1_000_000) * rates.input * inputMult +
972
+ (cached / 1_000_000) * (rates.cachedInput ?? rates.input) * inputMult +
973
+ (cacheWrite / 1_000_000) * (rates.cacheWriteInput ?? rates.input) * inputMult +
974
+ (output / 1_000_000) * rates.output * outputMult;
975
+ }
976
+
977
+ const entry = bucket(model, longContext);
978
+ entry.inputTokens += fresh;
979
+ entry.cachedInputTokens += cached;
980
+ entry.cacheWriteInputTokens += cacheWrite;
981
+ entry.outputTokens += output;
982
+ if (countTurn) entry.turns += 1;
983
+ },
984
+ /**
985
+ * Re-seed from another tracker's `modelBreakdown()` rows, preserving
986
+ * each row's per-step tier attribution and turn counts. Used by the
987
+ * claude runner's terminal merge: live per-step buckets seed the
988
+ * final tracker, then only the envelope residual is recorded as an
989
+ * aggregate.
990
+ */
991
+ seedFromBreakdown(rows = []) {
992
+ for (const row of Array.isArray(rows) ? rows : []) {
993
+ if (!row || typeof row !== "object" || !row.model) continue;
994
+ const cached = num(row.cachedInputTokens);
995
+ const cacheWrite = num(row.cacheWriteInputTokens);
996
+ this.record(
997
+ row.model,
998
+ {
999
+ // Breakdown rows carry COMBINED input; record() wants fresh-only.
1000
+ inputTokens: Math.max(0, num(row.inputTokens) - cached - cacheWrite),
1001
+ cachedInputTokens: cached,
1002
+ cacheWriteInputTokens: cacheWrite,
1003
+ outputTokens: num(row.outputTokens),
1004
+ },
1005
+ { countTurn: false, longContext: row.longContext === true }
1006
+ );
1007
+ bucket(row.model, row.longContext === true).turns += num(row.turns);
1008
+ }
1009
+ },
1010
+ /**
1011
+ * Note the context occupancy of ONE model request: its full
1012
+ * prompt-side token count (fresh input + cache reads + cache
1013
+ * writes). Callers invoke this per request; the last call wins and
1014
+ * feeds the dashboard's context-window fill indicator.
1015
+ * `windowTokens` is the engine-reported context-window size when
1016
+ * the engine exposes one (codex does; claude/opencode don't).
1017
+ */
1018
+ noteContextSnapshot(model, tokens, windowTokens) {
1019
+ const value = num(tokens);
1020
+ if (value <= 0) return;
1021
+ const window = num(windowTokens);
1022
+ lastContext = {
1023
+ model: String(model || "unknown"),
1024
+ tokens: value,
1025
+ ...(window > 0 ? { windowTokens: window } : {}),
1026
+ };
1027
+ },
1028
+ /** Last noted context snapshot ({model, tokens, windowTokens?}) or null. */
1029
+ contextSnapshot() {
1030
+ return lastContext;
1031
+ },
1032
+ /** Provider-reported authoritative USD (native Anthropic only). */
1033
+ setProviderCostUsd(value) {
1034
+ providerCostUsd = num(value);
1035
+ },
1036
+ /** USD priced per recorded step from the forwarded registry rates (per 1M tokens). */
1037
+ pricedCostUsd() {
1038
+ return pricedUsd;
1039
+ },
1040
+ /** Best-available USD for budget enforcement. */
1041
+ effectiveCostUsd() {
1042
+ const priced = this.pricedCostUsd();
1043
+ return Math.max(providerCostUsd, priced);
1044
+ },
1045
+ totals() {
1046
+ let inputTokens = 0;
1047
+ let cachedInputTokens = 0;
1048
+ let cacheWriteInputTokens = 0;
1049
+ let outputTokens = 0;
1050
+ for (const u of perModel.values()) {
1051
+ inputTokens += u.inputTokens + u.cachedInputTokens + u.cacheWriteInputTokens;
1052
+ cachedInputTokens += u.cachedInputTokens;
1053
+ cacheWriteInputTokens += u.cacheWriteInputTokens;
1054
+ outputTokens += u.outputTokens;
1055
+ }
1056
+ return { inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens };
1057
+ },
1058
+ modelBreakdown() {
1059
+ const out = [];
1060
+ for (const u of perModel.values()) {
1061
+ out.push({
1062
+ // Plain model id from the entry — the map key is composite for
1063
+ // tier-split buckets and must never leak (the Server resolves
1064
+ // rows via getModelCost(row.model)).
1065
+ model: u.model,
1066
+ inputTokens: u.inputTokens + u.cachedInputTokens + u.cacheWriteInputTokens,
1067
+ ...(u.cachedInputTokens > 0
1068
+ ? { cachedInputTokens: u.cachedInputTokens }
1069
+ : {}),
1070
+ ...(u.cacheWriteInputTokens > 0
1071
+ ? { cacheWriteInputTokens: u.cacheWriteInputTokens }
1072
+ : {}),
1073
+ outputTokens: u.outputTokens,
1074
+ turns: u.turns,
1075
+ // Explicit tier attribution — BOTH values, always. The host
1076
+ // treats a MISSING flag as "infer the tier from this row's
1077
+ // token totals", and these rows are per-model TOTALS across a
1078
+ // whole turn: a base-tier bucket that merely sums past the
1079
+ // threshold would be re-inferred long and billed the 2×/1.5×
1080
+ // surcharge (bit 90% of terra engine turns, 2026-07). The
1081
+ // explicit false is what disarms that inference.
1082
+ longContext: !!u.longContext,
1083
+ });
1084
+ }
1085
+ return out;
1086
+ },
1087
+ /**
1088
+ * Build the terminal `result` event. `costUsd` is the provider-
1089
+ * reported USD when available, otherwise the priced total (omitted
1090
+ * entirely when neither yields a positive number — the Server then
1091
+ * bills from the token fields).
1092
+ */
1093
+ buildResultEvent({ message = "", sessionId } = {}) {
1094
+ const totals = this.totals();
1095
+ const breakdown = this.modelBreakdown();
1096
+ const cost = this.effectiveCostUsd();
1097
+ return {
1098
+ type: "result",
1099
+ message,
1100
+ sessionId,
1101
+ costUsd: cost > 0 ? cost : undefined,
1102
+ inputTokens: totals.inputTokens || undefined,
1103
+ cachedInputTokens: totals.cachedInputTokens || undefined,
1104
+ cacheWriteInputTokens: totals.cacheWriteInputTokens || undefined,
1105
+ outputTokens: totals.outputTokens || undefined,
1106
+ ...(breakdown.length > 0 ? { modelBreakdown: breakdown } : {}),
1107
+ // Last request's context occupancy — a snapshot, unlike the
1108
+ // cumulative token fields above. Drives the context-window fill
1109
+ // indicator; absent when no request was noted.
1110
+ ...(lastContext
1111
+ ? {
1112
+ contextTokens: lastContext.tokens,
1113
+ contextModelId: lastContext.model,
1114
+ ...(lastContext.windowTokens
1115
+ ? { contextWindowTokens: lastContext.windowTokens }
1116
+ : {}),
1117
+ }
1118
+ : {}),
1119
+ };
1120
+ },
1121
+ };
1122
+ }