@cat-factory/executor-harness 1.31.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/agent-runner.js +389 -0
  4. package/dist/agent.js +810 -0
  5. package/dist/blueprint.js +367 -0
  6. package/dist/bootstrap.js +99 -0
  7. package/dist/ci-fixer.js +46 -0
  8. package/dist/coding-agent.js +285 -0
  9. package/dist/conflict-resolver.js +138 -0
  10. package/dist/embed.js +8 -0
  11. package/dist/explore.js +74 -0
  12. package/dist/failure.js +47 -0
  13. package/dist/fixer.js +44 -0
  14. package/dist/follow-ups.js +103 -0
  15. package/dist/frontend-infra.js +283 -0
  16. package/dist/fs-utils.js +11 -0
  17. package/dist/git.js +778 -0
  18. package/dist/job.js +409 -0
  19. package/dist/logger.js +27 -0
  20. package/dist/merger.js +135 -0
  21. package/dist/on-call.js +126 -0
  22. package/dist/pi-workspace.js +237 -0
  23. package/dist/pi.js +971 -0
  24. package/dist/process.js +25 -0
  25. package/dist/redact.js +109 -0
  26. package/dist/runner.js +228 -0
  27. package/dist/server.js +135 -0
  28. package/dist/spec.js +754 -0
  29. package/dist/structured-output.js +431 -0
  30. package/dist/tester.js +191 -0
  31. package/package.json +35 -0
  32. package/src/agent-runner.ts +484 -0
  33. package/src/agent.ts +948 -0
  34. package/src/coding-agent.ts +393 -0
  35. package/src/embed.ts +32 -0
  36. package/src/failure.ts +73 -0
  37. package/src/follow-ups.ts +106 -0
  38. package/src/frontend-infra.ts +340 -0
  39. package/src/fs-utils.ts +11 -0
  40. package/src/git.ts +955 -0
  41. package/src/job.ts +766 -0
  42. package/src/logger.ts +45 -0
  43. package/src/pi-workspace.ts +348 -0
  44. package/src/pi.ts +1236 -0
  45. package/src/process.ts +33 -0
  46. package/src/redact.ts +109 -0
  47. package/src/runner.ts +384 -0
  48. package/src/server.ts +153 -0
  49. package/src/structured-output.ts +524 -0
@@ -0,0 +1,484 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
6
+ import { killChildProcess } from './process.js'
7
+ import { redact, secretsToRedact } from './redact.js'
8
+
9
+ // The alternate (subscription) harness runners. The Pi harness reaches models
10
+ // through the LLM proxy with a model-locked session token; the Claude Code and
11
+ // Codex harnesses instead authenticate with a stored subscription OAuth token and
12
+ // talk DIRECT to the vendor. Everything around the inner loop — the HTTP job
13
+ // server, JobRegistry watchdogs, git clone/push, the handlers — is harness-
14
+ // agnostic, so only this inner "run the CLI" step differs.
15
+ //
16
+ // Each runner mirrors `runPi`'s contract: stream the CLI's JSON events, feed
17
+ // `onActivity` (inactivity watchdog) and `onProgress` (subtask counts) the way Pi
18
+ // does, and return a {@link PiRunOutcome}. Because the proxy never sees this
19
+ // traffic, the runners also lift per-turn token usage out of the CLI event stream
20
+ // onto the outcome, which the backend uses for usage-aware token rotation and
21
+ // telemetry. Event-schema details vary by CLI version, so the extractors below are
22
+ // deliberately defensive and degrade gracefully when a field is absent.
23
+
24
+ /** Which subscription harness to run (the Pi harness uses `runPi` directly). */
25
+ export type SubscriptionHarness = 'claude-code' | 'codex'
26
+
27
+ export interface SubscriptionRunOptions {
28
+ /** Prepared working directory (cloned/scaffolded by the caller). */
29
+ cwd: string
30
+ /** Real vendor model id, e.g. `claude-opus-4-8` / `gpt-5.5-codex`. */
31
+ model: string
32
+ /** Composed role + best-practice fragments, supplied as the system prompt. */
33
+ systemPrompt: string
34
+ /** The concrete task prompt handed to the CLI over stdin. */
35
+ userPrompt: string
36
+ /**
37
+ * The decrypted subscription credential: an OAuth token (claude) or auth.json blob
38
+ * (codex). Omitted when `ambientAuth` is set — the CLI uses the developer's own login.
39
+ */
40
+ subscriptionToken?: string
41
+ /**
42
+ * Anthropic-compatible base URL for a non-Anthropic Claude-Code vendor (GLM/Kimi).
43
+ * Present ⇒ ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN; absent ⇒ CLAUDE_CODE_OAUTH_TOKEN.
44
+ */
45
+ subscriptionBaseUrl?: string
46
+ /**
47
+ * Native local execution: run the developer's ALREADY-INSTALLED CLI with its OWN
48
+ * ambient login (`~/.claude` / `~/.codex`) — no leased credential, no isolated config
49
+ * home. Set ONLY by the local native transport (which runs the harness as a host
50
+ * process); a no-op everywhere else. The agent then runs with the user's personal
51
+ * subscription, unsandboxed, on their own machine — the explicit trade for skipping the
52
+ * container.
53
+ */
54
+ ambientAuth?: boolean
55
+ /** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
56
+ signal?: AbortSignal
57
+ /** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
58
+ onActivity?: () => void
59
+ /** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
60
+ onProgress?: (progress: TodoProgress) => void
61
+ }
62
+
63
+ function isObject(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === 'object' && value !== null
65
+ }
66
+
67
+ /**
68
+ * Drive one CLI subprocess to completion, streaming LF-framed JSONL from stdout
69
+ * through `onEvent`. Mirrors `runPi`'s lifecycle: prompt over stdin (out-of-band,
70
+ * never argv), `onActivity` on every chunk, abort kills the child, and the close
71
+ * handler resolves/rejects. The caller's `onEvent` accumulates the outcome.
72
+ *
73
+ * `prompt` is fed over stdin: for Claude Code that is just the task prompt (the
74
+ * system prompt rides `--append-system-prompt`); for Codex — which has no
75
+ * system-prompt flag — the caller prepends the composed system prompt to it so
76
+ * the role + best-practice context is not lost.
77
+ */
78
+ function streamCli(
79
+ command: string,
80
+ args: string[],
81
+ prompt: string,
82
+ opts: SubscriptionRunOptions,
83
+ env: Record<string, string>,
84
+ secrets: string[],
85
+ onEvent: (event: Record<string, unknown>) => void,
86
+ ): Promise<{ stderrTail: string }> {
87
+ return new Promise((resolve, reject) => {
88
+ if (opts.signal?.aborted) {
89
+ reject(new Error(`${command} aborted before start`))
90
+ return
91
+ }
92
+ const child = spawn(command, args, {
93
+ cwd: opts.cwd,
94
+ env: { ...process.env, ...env },
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ })
97
+ child.stdin.on('error', () => {})
98
+ child.stdin.end(prompt)
99
+
100
+ let stderr = ''
101
+ let aborted = false
102
+ let lineBuffer = ''
103
+
104
+ const killChild = (): void => killChildProcess(child)
105
+
106
+ const processLine = (line: string): void => {
107
+ if (!line.startsWith('{')) return
108
+ let event: Record<string, unknown>
109
+ try {
110
+ event = JSON.parse(line) as Record<string, unknown>
111
+ } catch {
112
+ return
113
+ }
114
+ try {
115
+ onEvent(event)
116
+ } catch {
117
+ // A faulty observer must never break the run.
118
+ }
119
+ }
120
+
121
+ const consumeStdout = (text: string): void => {
122
+ lineBuffer += text
123
+ let nl = lineBuffer.indexOf('\n')
124
+ while (nl !== -1) {
125
+ const line = lineBuffer.slice(0, nl).trim()
126
+ lineBuffer = lineBuffer.slice(nl + 1)
127
+ nl = lineBuffer.indexOf('\n')
128
+ processLine(line)
129
+ }
130
+ }
131
+
132
+ const onAbort = (): void => {
133
+ aborted = true
134
+ killChild()
135
+ }
136
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
137
+
138
+ child.stdout.on('data', (chunk: Buffer) => {
139
+ opts.onActivity?.()
140
+ consumeStdout(chunk.toString())
141
+ })
142
+ child.stderr.on('data', (chunk: Buffer) => {
143
+ opts.onActivity?.()
144
+ stderr += chunk.toString()
145
+ if (stderr.length > 8_000) stderr = stderr.slice(-8_000)
146
+ })
147
+
148
+ child.on('error', (err) => {
149
+ opts.signal?.removeEventListener('abort', onAbort)
150
+ reject(err)
151
+ })
152
+ child.on('close', (code) => {
153
+ opts.signal?.removeEventListener('abort', onAbort)
154
+ if (lineBuffer.trim()) processLine(lineBuffer.trim())
155
+ const stderrTail = redact(stderr, secrets).slice(-700)
156
+ if (aborted) {
157
+ reject(new Error('agent run aborted by watchdog'))
158
+ return
159
+ }
160
+ if (code !== 0) {
161
+ reject(new Error(`${command} exited with code ${code}: ${stderrTail}`))
162
+ return
163
+ }
164
+ resolve({ stderrTail })
165
+ })
166
+ })
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Claude Code
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Run the Claude Code CLI headlessly against `opts.cwd`, authenticated with the
175
+ * leased subscription OAuth token (CLAUDE_CODE_OAUTH_TOKEN), talking direct to
176
+ * api.anthropic.com. Streams `--output-format stream-json`, mapping the
177
+ * `TodoWrite` tool calls onto subtask progress and the terminal `result` event
178
+ * onto the summary + usage.
179
+ */
180
+ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
181
+ const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
182
+ let summary = ''
183
+ let usage: { inputTokens: number; outputTokens: number } | undefined
184
+
185
+ const onEvent = (event: Record<string, unknown>): void => {
186
+ const type = event.type
187
+ if (type === 'assistant' && isObject(event.message)) {
188
+ const content = (event.message as Record<string, unknown>).content
189
+ if (Array.isArray(content)) {
190
+ for (const block of content) {
191
+ if (!isObject(block)) continue
192
+ if (block.type === 'text' && typeof block.text === 'string') {
193
+ stats.assistantChars += block.text.length
194
+ }
195
+ if (block.type === 'tool_use') {
196
+ stats.toolCalls += 1
197
+ if (block.name === 'TodoWrite' && opts.onProgress) {
198
+ const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
199
+ if (progress) opts.onProgress(progress)
200
+ }
201
+ }
202
+ }
203
+ }
204
+ } else if (type === 'result') {
205
+ if (typeof event.result === 'string') summary = event.result
206
+ usage = claudeUsage(event.usage) ?? usage
207
+ }
208
+ }
209
+
210
+ // Native (ambient) mode: run the developer's installed `claude` with its OWN login —
211
+ // no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
212
+ // Claude Code persists user config/credentials under its config dir; point that at an
213
+ // isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
214
+ // agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
215
+ // stray `.claude/` directory — and any cached credential in it — into the pushed branch.
216
+ // Mirrors the Codex CODEX_HOME isolation below; removed in `finally`.
217
+ if (!opts.ambientAuth && !opts.subscriptionToken) {
218
+ throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
219
+ }
220
+ const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
221
+
222
+ // The config dir is brand-new every run, so Claude Code would otherwise treat this
223
+ // as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
224
+ // bypass-permissions acknowledgement prompts — which never get answered headlessly,
225
+ // hanging the job until the watchdog kills it. Pre-seed the config that marks those
226
+ // as already accepted so `-p` starts straight into the run. Best-effort: written
227
+ // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
228
+ // (Ambient mode skips this — the developer's own config is already onboarded.)
229
+ if (configHome) {
230
+ await writeFile(
231
+ join(configHome, '.claude.json'),
232
+ JSON.stringify({
233
+ hasCompletedOnboarding: true,
234
+ bypassPermissionsModeAccepted: true,
235
+ hasTrustDialogAccepted: true,
236
+ }),
237
+ { mode: 0o600 },
238
+ ).catch(() => {})
239
+ }
240
+
241
+ // Anthropic itself authenticates with the subscription OAuth token; a
242
+ // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
243
+ // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
244
+ // Ambient mode injects neither — the CLI uses the developer's logged-in `~/.claude`.
245
+ const env: Record<string, string> = opts.ambientAuth
246
+ ? {}
247
+ : {
248
+ CLAUDE_CONFIG_DIR: configHome!,
249
+ ...(opts.subscriptionBaseUrl
250
+ ? {
251
+ ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
252
+ ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken!,
253
+ }
254
+ : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
255
+ }
256
+
257
+ try {
258
+ const { stderrTail } = await streamCli(
259
+ 'claude',
260
+ [
261
+ '-p',
262
+ '--output-format',
263
+ 'stream-json',
264
+ '--verbose',
265
+ // The per-run container IS the sandbox, and the run is fully headless (no one
266
+ // to approve a tool call) — so bypass permissions entirely. `acceptEdits`
267
+ // would auto-accept file edits but still gate Bash, which in `-p` mode is then
268
+ // denied, leaving the agent unable to run builds/tests/git to verify its work.
269
+ '--permission-mode',
270
+ 'bypassPermissions',
271
+ '--model',
272
+ opts.model,
273
+ '--append-system-prompt',
274
+ opts.systemPrompt,
275
+ ],
276
+ opts.userPrompt,
277
+ opts,
278
+ env,
279
+ opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
280
+ onEvent,
281
+ )
282
+
283
+ return { summary, stats, stderrTail, ...(usage ? { usage } : {}) }
284
+ } finally {
285
+ // Never leave the config dir (and any cached credential) on disk past the run.
286
+ if (configHome) await rm(configHome, { recursive: true, force: true }).catch(() => {})
287
+ }
288
+ }
289
+
290
+ /** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
291
+ function todosToProgress(todos: unknown): TodoProgress | undefined {
292
+ if (!Array.isArray(todos)) return undefined
293
+ const items = todos.filter(isObject).map((t) => ({
294
+ label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
295
+ status: normalizeStatus(t.status),
296
+ }))
297
+ const completed = items.filter((i) => i.status === 'completed').length
298
+ const inProgress = items.filter((i) => i.status === 'in_progress').length
299
+ return { completed, inProgress, total: items.length, items }
300
+ }
301
+
302
+ function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' {
303
+ if (status === 'completed') return 'completed'
304
+ if (status === 'in_progress') return 'in_progress'
305
+ return 'pending'
306
+ }
307
+
308
+ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number } | undefined {
309
+ if (!isObject(raw)) return undefined
310
+ // Count every input bucket Anthropic bills: fresh input plus BOTH cache reads and
311
+ // cache writes (cache_creation_input_tokens), which are real consumed tokens — and
312
+ // are the dominant share on a long agent run. Omitting them under-weights a token's
313
+ // true load in the usage-aware rotation window.
314
+ const input =
315
+ numberOf(raw.input_tokens) +
316
+ numberOf(raw.cache_read_input_tokens) +
317
+ numberOf(raw.cache_creation_input_tokens)
318
+ const output = numberOf(raw.output_tokens)
319
+ if (input === 0 && output === 0) return undefined
320
+ return { inputTokens: input, outputTokens: output }
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Codex
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Run the Codex CLI headlessly against `opts.cwd`, authenticated with the leased
329
+ * ChatGPT `auth.json` bundle written to an isolated CODEX_HOME, talking direct to
330
+ * the ChatGPT backend. Streams `codex exec --json`, mapping plan/todo updates onto
331
+ * subtask progress and the running cumulative token usage onto the outcome.
332
+ */
333
+ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
334
+ const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
335
+ let summary = ''
336
+ let usage: { inputTokens: number; outputTokens: number } | undefined
337
+
338
+ // Codex reads its credentials from $CODEX_HOME/auth.json with file-backed
339
+ // storage. CRITICAL: this home must live OUTSIDE the cloned checkout (`opts.cwd`)
340
+ // — the blueprint/requirements/conflict-resolver handlers finish with
341
+ // `git add -A` + push, which would otherwise stage and publish the decrypted
342
+ // subscription `auth.json` (access + refresh tokens) to the PR branch. An
343
+ // isolated, per-run temp dir keeps the credential out of the working tree and is
344
+ // removed in `finally`.
345
+ //
346
+ // KNOWN LIMITATION: Codex refreshes its OAuth access token in-place by rewriting
347
+ // this `auth.json` mid-run. Because the home is a per-run temp dir wiped in
348
+ // `finally`, that refreshed credential is discarded and never written back to the
349
+ // pool — there is no write-back path. The stored bundle keeps working as long as
350
+ // its refresh token stays valid (ChatGPT refresh tokens are long-lived and reused,
351
+ // not rotated per refresh today), so each run re-refreshes from the same stored
352
+ // copy; if OpenAI ever rotates refresh tokens on use, a pooled Codex token would
353
+ // eventually need to be re-connected by the user. Claude OAuth tokens (from
354
+ // `claude setup-token`) are long-lived and unaffected.
355
+ // Native (ambient) mode: run the developer's installed `codex` with its OWN login —
356
+ // no isolated CODEX_HOME, no injected auth.json. Otherwise write the leased credential
357
+ // to a per-run temp home kept OUTSIDE the checkout (and removed in `finally`).
358
+ if (!opts.ambientAuth && !opts.subscriptionToken) {
359
+ throw new Error('codex harness requires a subscription token (or ambientAuth)')
360
+ }
361
+ const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'))
362
+ if (codexHome) {
363
+ await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
364
+ await writeFile(join(codexHome, 'config.toml'), 'cli_auth_credentials_store = "file"\n', 'utf8')
365
+ }
366
+
367
+ const onEvent = (event: Record<string, unknown>): void => {
368
+ const type = typeof event.type === 'string' ? event.type : ''
369
+ if (type.includes('agent_message') || type === 'item.completed') {
370
+ const text = extractText(event)
371
+ if (text) {
372
+ stats.assistantChars += text.length
373
+ summary = text
374
+ }
375
+ }
376
+ if (type.includes('tool') || type.includes('command') || type.includes('exec')) {
377
+ stats.toolCalls += 1
378
+ }
379
+ const progress = codexPlanProgress(event)
380
+ if (progress && opts.onProgress) opts.onProgress(progress)
381
+ const turnUsage = codexUsage(event)
382
+ if (turnUsage) usage = turnUsage
383
+ }
384
+
385
+ // Codex has no system-prompt flag, so fold the composed role + best-practice
386
+ // context into the prompt itself (Claude Code instead rides --append-system-prompt).
387
+ const prompt = opts.systemPrompt
388
+ ? `${opts.systemPrompt}\n\n---\n\n${opts.userPrompt}`
389
+ : opts.userPrompt
390
+
391
+ try {
392
+ const { stderrTail } = await streamCli(
393
+ 'codex',
394
+ [
395
+ 'exec',
396
+ '--json',
397
+ '--skip-git-repo-check',
398
+ // The per-run container IS the sandbox; let Codex write files and reach the
399
+ // vendor unrestricted, with no approval prompts (the run is headless).
400
+ '--dangerously-bypass-approvals-and-sandbox',
401
+ '--model',
402
+ opts.model,
403
+ '-',
404
+ ],
405
+ prompt,
406
+ opts,
407
+ codexHome ? { CODEX_HOME: codexHome } : {},
408
+ opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
409
+ onEvent,
410
+ )
411
+
412
+ return { summary, stats, stderrTail, ...(usage ? { usage } : {}) }
413
+ } finally {
414
+ // Never leave the decrypted credential on disk past the run.
415
+ if (codexHome) await rm(codexHome, { recursive: true, force: true }).catch(() => {})
416
+ }
417
+ }
418
+
419
+ /** Best-effort: pull a textual message out of a Codex event. */
420
+ function extractText(event: Record<string, unknown>): string | undefined {
421
+ if (typeof event.message === 'string') return event.message
422
+ if (typeof event.text === 'string') return event.text
423
+ if (isObject(event.item)) {
424
+ const item = event.item as Record<string, unknown>
425
+ if (typeof item.text === 'string') return item.text
426
+ if (typeof item.message === 'string') return item.message
427
+ }
428
+ return undefined
429
+ }
430
+
431
+ /** Best-effort: map a Codex `update_plan`/plan event onto subtask counts. */
432
+ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undefined {
433
+ const plan =
434
+ (isObject(event.plan) ? event.plan : undefined) ??
435
+ (isObject(event.item) && Array.isArray((event.item as Record<string, unknown>).plan)
436
+ ? { steps: (event.item as Record<string, unknown>).plan }
437
+ : undefined)
438
+ const steps = isObject(plan) ? plan.steps : Array.isArray(event.steps) ? event.steps : undefined
439
+ if (!Array.isArray(steps)) return undefined
440
+ const items = steps.filter(isObject).map((s) => ({
441
+ label: typeof s.step === 'string' ? s.step : String(s.step ?? s.content ?? ''),
442
+ status: normalizeStatus(s.status),
443
+ }))
444
+ if (items.length === 0) return undefined
445
+ const completed = items.filter((i) => i.status === 'completed').length
446
+ const inProgress = items.filter((i) => i.status === 'in_progress').length
447
+ return { completed, inProgress, total: items.length, items }
448
+ }
449
+
450
+ /**
451
+ * Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
452
+ * reports a running CUMULATIVE total on `token_count` events under
453
+ * `info.total_token_usage` (it also carries the per-turn `last_token_usage`); older /
454
+ * other shapes put it on `usage` / `info.usage` directly. We read the cumulative
455
+ * total when present so the caller can simply overwrite (not sum) — summing
456
+ * cumulative totals across events would multiply-count. Checked most-likely first.
457
+ */
458
+ function codexUsage(
459
+ event: Record<string, unknown>,
460
+ ): { inputTokens: number; outputTokens: number } | undefined {
461
+ const info = isObject(event.info) ? (event.info as Record<string, unknown>) : undefined
462
+ const raw =
463
+ (info && isObject(info.total_token_usage) ? info.total_token_usage : undefined) ??
464
+ (isObject(event.total_token_usage) ? event.total_token_usage : undefined) ??
465
+ (isObject(event.usage) ? event.usage : undefined) ??
466
+ (info && isObject(info.usage) ? info.usage : undefined)
467
+ if (!isObject(raw)) return undefined
468
+ const input = numberOf(raw.input_tokens) + numberOf(raw.cached_input_tokens)
469
+ const output = numberOf(raw.output_tokens)
470
+ if (input === 0 && output === 0) return undefined
471
+ return { inputTokens: input, outputTokens: output }
472
+ }
473
+
474
+ function numberOf(value: unknown): number {
475
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
476
+ }
477
+
478
+ /** Dispatch to the configured subscription harness runner. */
479
+ export function runSubscriptionHarness(
480
+ harness: SubscriptionHarness,
481
+ opts: SubscriptionRunOptions,
482
+ ): Promise<PiRunOutcome> {
483
+ return harness === 'claude-code' ? runClaudeCode(opts) : runCodex(opts)
484
+ }