codeburn/app/electron/cli.ts
iamtoruk d78ab77d96 perf: resident serve process for the desktop app — panel fetches in milliseconds
Every CLI spawn on a large corpus pays seconds of fixed cost before any
query work: node boot, a 100MB+ session-cache JSON.parse, the discovery +
fingerprint sweep, and serve-time classification. The desktop app spawns
one CLI per panel fetch, so it pays that cost per panel.

codeburn serve --stdio is the same CLI kept warm: the app holds one child,
sends {id, args} per line, and gets the command's stdout back. Three layers
make it fast, each disabled outside serve so one-shot runs stay byte-exact:

- loadCache memo (session-cache.ts): the parsed cache object is reused
  while a stat() shows the file unchanged; saveCache updates it
  write-through. A rewrite by another process still forces a fresh read.
- burst reuse (parser.ts, CODEBURN_PARSE_BURST_MS, serve sets 10s): panel
  bursts anchor their range ends at their own new Date(), so the exact-key
  memo never hits in real traffic; within the window a re-anchored range is
  served by trimming the previous parse instead of re-running discovery.
- fresh commander program per request (main.ts buildProgram factory),
  because commander option state is sticky across parses.

The server allows only the app's read queries (status/overview/models/
sessions/compare/yield/spend/optimize/audit), refuses everything else
(client falls back to a spawn), serializes requests, and converts
process.exit into a caught signal. The app starts the child once at
startup; requests route through it only when warm, cold-start keeps the
spawn path with its progress events, any serve failure falls back to a
spawn, and three child deaths disable serve for the app run.

Measured on a real 17B-token corpus: panel fetches drop from ~7.4s per
spawn to 5-900ms warm (sessions/spend 5ms, status 898ms). One-shot CLI
output verified byte-identical against the pre-branch baseline.
2026-08-10 09:43:44 -07:00

619 lines
25 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process'
import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { delimiter, dirname, isAbsolute, join } from 'node:path'
// Runs entirely in the Electron main process. This module must NOT import
// `electron` so it stays unit-testable in a plain node environment.
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
export type ActionResult = { ok: boolean; stdout: string; stderr: string; code: number | null }
/**
* Scheduling class for a CLI spawn. 'interactive' is what a user is waiting on
* (the visible poll, a Settings mutation); 'background' is speculative work
* (the overview prefetch). Interactive always dequeues first, so a burst of
* background warms can never delay the fetch behind a click.
*/
export type SpawnPriority = 'interactive' | 'background'
/**
* A resolved CLI target. `external` is a standalone `codeburn` executable (the
* dev repo build, a persisted path, or one found on PATH) spawned directly.
* `bundled` is the copy shipped inside the packaged app under `resources/cli`;
* it has no Node runner of its own, so it is spawned with Electron's own binary
* acting as Node via `ELECTRON_RUN_AS_NODE`.
*/
export type CliTarget = { kind: 'external'; bin: string } | { kind: 'bundled'; entry: string }
type SpawnSpec = { bin: string; args: string[]; env: NodeJS.ProcessEnv }
/**
* Which resolution/spawn stage produced a `not-found`, as a non-sensitive enum
* for telemetry. Never contains a path — just names what was missing so a
* not-found is self-diagnosing without a repro. See {@link notFoundStage}.
*/
export type NotFoundStage =
| 'bin-not-absolute'
| 'bin-not-executable'
| 'bundled-not-absolute'
| 'bundled-missing'
| 'spawn-error'
| 'no-path-match'
/** Structured failure so the renderer can pick the right empty/permission state. */
export class CliError extends Error {
readonly kind: CliErrorKind
/** For `not-found` only: the non-sensitive stage enum. Undefined otherwise. */
readonly detail?: NotFoundStage
constructor(kind: CliErrorKind, message: string, detail?: NotFoundStage) {
super(message)
this.name = 'CliError'
this.kind = kind
this.detail = detail
}
}
const DEFAULT_TIMEOUT_MS = 45_000
// A runaway CLI (or a compromised binary) must not exhaust main-process memory.
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024
// Same-cadence pollers fire near-identical read spawns; share one child and hold
// its result briefly so six overview hooks don't launch six processes at once.
const COALESCE_TTL_MS = 5_000
// A cold-cache CLI spawn costs seconds at ~120% CPU; letting every poll +
// prefetch launch at once saturates the machine. Cap how many children run
// concurrently — the rest queue and drain as slots free (interactive first).
const MAX_CONCURRENT_CLI = 2
// Every live child so `before-quit` can reap them (Electron does not on macOS).
const activeChildren = new Set<ChildProcess>()
const readInflight = new Map<string, Promise<unknown>>()
const readCache = new Map<string, { at: number; value: unknown }>()
// Concurrency scheduler. `running` counts spawned (not queued) children; waiters
// hold the slot-grant resolver for a queued spawn. Two queues so interactive
// work jumps ahead of any already-queued background warm the moment a slot frees.
type SlotWaiter = { resolve: () => void; reject: (err: unknown) => void }
let running = 0
const interactiveQueue: SlotWaiter[] = []
const backgroundQueue: SlotWaiter[] = []
/** Grant free slots to queued waiters, interactive first, up to the cap. */
function pumpSlots(): void {
while (running < MAX_CONCURRENT_CLI) {
const waiter = interactiveQueue.shift() ?? backgroundQueue.shift()
if (!waiter) return
running += 1
waiter.resolve()
}
}
/** Resolve once a run slot is free. The per-call timeout starts only after this
* resolves (i.e. at real spawn time), never while queued. */
function acquireSlot(priority: SpawnPriority): Promise<void> {
return new Promise<void>((resolve, reject) => {
;(priority === 'background' ? backgroundQueue : interactiveQueue).push({ resolve, reject })
pumpSlots()
})
}
function releaseSlot(): void {
running = Math.max(0, running - 1)
pumpSlots()
}
/** SIGKILL every in-flight child and cancel anything still queued for a slot.
* Wired to Electron's `before-quit`. */
export function killAll(): void {
serveClient?.destroy()
serveClient = null
for (const child of activeChildren) child.kill('SIGKILL')
activeChildren.clear()
// A queued waiter has no child to reap, so releaseSlot never fires for it;
// reject it explicitly so its caller settles instead of hanging past quit.
const waiting = [...interactiveQueue, ...backgroundQueue]
interactiveQueue.length = 0
backgroundQueue.length = 0
running = 0
for (const waiter of waiting) waiter.reject(new CliError('nonzero', 'codeburn cancelled'))
}
// Homebrew + common Node version managers, mirroring mac/CodeburnCLI.swift so a
// GUI-launched app (minimal PATH) still finds a globally-installed `codeburn`.
export function nodeManagerDirs(): string[] {
const home = homedir()
const dirs = [
'/opt/homebrew/bin',
'/usr/local/bin',
join(home, '.volta', 'bin'),
join(home, '.npm-global', 'bin'),
join(home, '.asdf', 'shims'),
]
const nvmDir = process.env.NVM_DIR || join(home, '.nvm')
const nvmVersions = join(nvmDir, 'versions', 'node')
try {
// Scan version dirs newest-first and take the first whose bin actually holds
// `codeburn`. A lexicographic max ("v9" > "v22") is not a real "newest", and
// the top dir may not even contain the CLI — so verify, matching CodeburnCLI.swift.
const entries = readdirSync(nvmVersions).sort().reverse()
for (const entry of entries) {
const bin = join(nvmVersions, entry, 'bin')
if (isExecutableFile(join(bin, 'codeburn'))) {
dirs.push(bin)
break
}
}
} catch {
// no nvm — ignore
}
return dirs
}
/** The dirs searched for a `codeburn` executable. `CODEBURN_PATH_DIRS` overrides
* the whole search space (delimiter-separated) — used by tests and advanced setups. */
function searchDirs(): string[] {
const override = process.env.CODEBURN_PATH_DIRS
if (override !== undefined) return override.split(delimiter).filter(Boolean)
const pathDirs = (process.env.PATH || '').split(delimiter).filter(Boolean)
return [...pathDirs, ...nodeManagerDirs()]
}
/**
* Spawn env for the resolved CLI. A GUI-launched app inherits a minimal PATH
* (/usr/bin:/bin:...) that lacks the user's node install, and the `codeburn`
* npm shim starts with `#!/usr/bin/env node` — so spawning it fails with
* "env: node: No such file or directory" even though the shim itself was
* found. Prepend the shim's own directory (node sits beside it in nvm,
* Homebrew, and npm-prefix layouts) plus the same dirs the resolver searches.
*/
export function spawnEnvFor(bin: string): NodeJS.ProcessEnv {
const parts = [dirname(bin), ...searchDirs(), ...(process.env.PATH || '').split(delimiter)]
const seen = new Set<string>()
const path = parts.filter(p => p && !seen.has(p) && (seen.add(p), true)).join(delimiter)
return { ...process.env, PATH: path }
}
/**
* The concrete spawn (executable, argv, env) for a resolved target. An external
* `codeburn` runs directly with the PATH augmentation above. The bundled copy
* has no runner of its own, so it runs as `process.execPath` (Electron's binary)
* with `ELECTRON_RUN_AS_NODE=1` turning it into plain Node and the bundle path
* as the first argument. PATH is still augmented so anything the CLI itself
* shells out to (pairing, sync) resolves the same way an external CLI would.
*/
export function spawnSpecFor(target: CliTarget, args: string[]): SpawnSpec {
if (target.kind === 'bundled') {
return {
bin: process.execPath,
args: [target.entry, ...args],
env: { ...spawnEnvFor(target.entry), ELECTRON_RUN_AS_NODE: '1' },
}
}
return { bin: target.bin, args, env: spawnEnvFor(target.bin) }
}
function isExecutableFile(p: string): boolean {
try {
if (!statSync(p).isFile()) return false
accessSync(p, constants.X_OK)
return true
} catch {
return false
}
}
function isFile(p: string): boolean {
try {
return statSync(p).isFile()
} catch {
return false
}
}
// Persisted-path file written by the (future) first-run "locate CLI" flow,
// mirroring the mac app's Application Support/CodeBurn/codeburn-cli-path.v1.
function persistedPathFile(): string {
const override = process.env.CODEBURN_CLI_PATH_FILE
if (override) return override
const home = homedir()
if (platform() === 'darwin') {
return join(home, 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1')
}
const base = process.env.XDG_CONFIG_HOME || join(home, '.config')
return join(base, 'CodeBurn', 'codeburn-cli-path.v1')
}
function readPersistedPath(): string | null {
try {
const file = persistedPathFile()
if (!existsSync(file)) return null
const value = readFileSync(file, 'utf-8').trim()
if (value && isAbsolute(value) && isExecutableFile(value)) return value
} catch {
// unreadable — fall through to PATH search
}
return null
}
/**
* Resolve which `codeburn` to run, or null if none is available.
* Order: dev override (`CODEBURN_BIN`) → repo CLI in Vite development →
* bundled CLI shipped in the packaged app (`CODEBURN_BUNDLED_CLI`) →
* persisted-path file → PATH / brew / nvm / volta / asdf → null.
*
* The dev repo CLI intentionally beats both the bundled and persisted paths: in
* `npm run dev` the developer is iterating on this repo, so its freshly-built
* `dist/cli.js` must win over anything older (which may lack newly-added
* commands). The bundled CLI beats the persisted/PATH ones so a packaged app is
* version-matched to itself and never falls back to an older globally-installed
* `codeburn`. `CODEBURN_BIN` still overrides everything. In an unpackaged
* dev/test run `CODEBURN_BUNDLED_CLI` is unset, so resolution behaves exactly as
* before.
*/
export function resolveTarget(): CliTarget | null {
const override = process.env.CODEBURN_BIN
if (override && isAbsolute(override) && isExecutableFile(override)) return { kind: 'external', bin: override }
// Dev convenience: when launched by the Vite dev server, prefer the repo's own
// freshly-built CLI over a stale globally-installed/persisted one, so
// newly-added commands (sessions/compare/act JSON) work without CODEBURN_BIN.
if (process.env.VITE_DEV_SERVER_URL) {
const devRepoRoot = process.env.CODEBURN_DEV_REPO_ROOT
if (devRepoRoot) {
// Test/advanced override, matching CODEBURN_PATH_DIRS: keep dev lookup in an isolated repo root.
const devBin = join(devRepoRoot, 'dist', 'cli.js')
if (isExecutableFile(devBin)) return { kind: 'external', bin: devBin }
} else {
const devBin = join(__dirname, '..', '..', '..', 'dist', 'cli.js')
if (isExecutableFile(devBin)) return { kind: 'external', bin: devBin }
// Vitest loads this source module from app/electron rather than the emitted
// app/dist/electron directory; keep the same repo CLI discoverable there.
const sourceDevBin = join(__dirname, '..', '..', 'dist', 'cli.js')
if (isExecutableFile(sourceDevBin)) return { kind: 'external', bin: sourceDevBin }
}
}
// Packaged app: main.ts sets CODEBURN_BUNDLED_CLI to resources/cli/dist/cli.js.
// It is passed as an argument to Electron-as-node, so it only needs to be a
// readable file — no exec bit or working shebang required.
const bundled = process.env.CODEBURN_BUNDLED_CLI
if (bundled && isAbsolute(bundled) && isFile(bundled)) return { kind: 'bundled', entry: bundled }
const persisted = readPersistedPath()
if (persisted) return { kind: 'external', bin: persisted }
for (const bin of searchDirs().map(dir => join(dir, 'codeburn'))) {
if (isExecutableFile(bin)) return { kind: 'external', bin }
}
return null
}
/** The resolved CLI's path for display/status, or null. See {@link resolveTarget}. */
export function resolveCodeburnPath(): string | null {
const target = resolveTarget()
if (!target) return null
return target.kind === 'bundled' ? target.entry : target.bin
}
/**
* Why {@link resolveTarget} found nothing, recomputed from env as a
* non-sensitive enum for telemetry. Mirrors resolveTarget's order and reports
* the first stage that disqualified a candidate (e.g. a bundled path that isn't
* absolute — the Windows P0 — vs. one that's absolute but missing, vs. no PATH
* match at all). Only enum strings escape here: never a path, arg, or message.
*/
export function notFoundStage(): NotFoundStage {
const override = process.env.CODEBURN_BIN
if (override) {
if (!isAbsolute(override)) return 'bin-not-absolute'
if (!isExecutableFile(override)) return 'bin-not-executable'
}
const bundled = process.env.CODEBURN_BUNDLED_CLI
if (bundled) {
if (!isAbsolute(bundled)) return 'bundled-not-absolute'
if (!isFile(bundled)) return 'bundled-missing'
}
return 'no-path-match'
}
function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: (chunk: string) => void): Promise<unknown> {
return new Promise<unknown>((resolve, reject) => {
const child = spawn(spec.bin, spec.args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spec.env })
activeChildren.add(child)
let stdout = ''
let stderr = ''
let total = 0
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
clearTimeout(timer)
activeChildren.delete(child)
fn()
}
const timer = setTimeout(() => {
finish(() => {
child.kill('SIGKILL')
reject(new CliError('timeout', `codeburn ${cmdLabel} timed out after ${timeoutMs}ms`))
})
}, timeoutMs)
const bump = (n: number) => {
total += n
if (total > MAX_OUTPUT_BYTES) {
finish(() => {
child.kill('SIGKILL')
reject(new CliError('too-large', `codeburn ${cmdLabel} produced more than ${MAX_OUTPUT_BYTES} bytes`))
})
}
}
child.stdout.on('data', chunk => { stdout += chunk; bump(chunk.length) })
child.stderr.on('data', chunk => {
stderr += chunk
bump(chunk.length)
// Live stderr for the cold-start warmup: forwards CLI scan-progress lines
// to the splash. Never fires for ordinary reads (onStderr unset).
if (onStderr) { try { onStderr(chunk.toString()) } catch { /* forwarder must not kill the read */ } }
})
child.on('error', err => {
finish(() => reject(new CliError('not-found', err.message, 'spawn-error')))
})
child.on('close', code => {
finish(() => {
if (code !== 0) {
reject(new CliError('nonzero', stderr.trim() || `codeburn exited with code ${code}`))
return
}
try {
resolve(JSON.parse(stdout))
} catch {
reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON'))
}
})
})
})
}
/**
* Spawn `codeburn <args>` with plain argv (never a shell), collect stdout, and
* decode it as JSON. Rejects with a structured {@link CliError}:
* not-found no binary resolved
* nonzero process exited with a non-zero code (stderr surfaced)
* bad-json stdout was not valid JSON
* timeout the process was killed after `timeoutMs`
* too-large stdout+stderr exceeded {@link MAX_OUTPUT_BYTES}
*
* Read-only, so concurrent identical calls share one child and a 5s result cache
* absorbs same-cadence pollers. Never use this for config-mutating commands.
*/
// ── Resident serve child ────────────────────────────────────────────────
// The heavy read queries (one per panel) each pay seconds of CLI startup on
// a large corpus: node boot + a 100MB+ session-cache JSON.parse before any
// query work. `codeburn serve` is the same CLI kept warm: requests go over
// stdio and the cache stays parsed in the child. Routing rules keep this
// strictly an optimization:
// - only SERVE_ROUTED commands (the app's JSON panel queries) are eligible;
// - requests route through serve only once the child is READY AND WARM, so
// the cold-start path keeps its spawn (with its stderr progress events);
// - any serve failure falls back to a normal spawn for that call;
// - three child deaths permanently disable serve for this app run.
const SERVE_ROUTED = new Set(['status', 'models', 'sessions', 'compare', 'yield', 'spend', 'optimize', 'audit'])
const SERVE_REQUEST_TIMEOUT_MS = 60_000
const SERVE_MAX_RESTARTS = 3
class ServeClient {
private child: ReturnType<typeof spawn> | null = null
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>()
private nextId = 1
private ready = false
private warm = false
private deaths = 0
private buffer = ''
constructor(private readonly spec: SpawnSpec) {}
isWarmAndReady(): boolean { return this.ready && this.warm && this.child !== null }
disabled(): boolean { return this.deaths >= SERVE_MAX_RESTARTS }
start(): void {
if (this.child || this.disabled()) return
const child = spawn(this.spec.bin, [...this.spec.args], { shell: false, stdio: ['pipe', 'pipe', 'ignore'], env: this.spec.env })
this.child = child
child.stdout!.setEncoding('utf8')
child.stdout!.on('data', (chunk: string) => this.onData(chunk))
const onGone = () => this.onDeath()
child.on('exit', onGone)
child.on('error', onGone)
// Background warm-up: one cheap query makes the child parse the session
// cache once; every later panel fetch reuses the in-memory copy.
void this.request(['status', '--format', 'menubar-json', '--period', 'today'], SERVE_REQUEST_TIMEOUT_MS)
.then(() => { this.warm = true })
.catch(() => { /* warm-up failure just leaves routing on the spawn path */ })
}
private onData(chunk: string): void {
this.buffer += chunk
let idx: number
while ((idx = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, idx).trim()
this.buffer = this.buffer.slice(idx + 1)
if (!line) continue
let msg: { id?: number; ready?: boolean; ok?: boolean; refused?: boolean; output?: string; error?: string }
try { msg = JSON.parse(line) } catch { continue }
if (msg.ready) { this.ready = true; continue }
if (typeof msg.id !== 'number') continue
const waiter = this.pending.get(msg.id)
if (!waiter) continue
this.pending.delete(msg.id)
clearTimeout(waiter.timer)
if (msg.ok && typeof msg.output === 'string') {
try { waiter.resolve(JSON.parse(msg.output)) }
catch { waiter.reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON')) }
} else {
waiter.reject(new CliError('nonzero', msg.error ?? 'serve request failed'))
}
}
}
private onDeath(): void {
const child = this.child
this.child = null
this.ready = false
this.warm = false
this.deaths += 1
if (child) activeChildren.delete(child as never)
for (const [, waiter] of this.pending) {
clearTimeout(waiter.timer)
waiter.reject(new CliError('nonzero', 'codeburn serve exited'))
}
this.pending.clear()
}
request(args: string[], timeoutMs: number): Promise<unknown> {
const child = this.child
if (!child?.stdin) return Promise.reject(new CliError('nonzero', 'serve not running'))
const id = this.nextId++
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
// A hung request would block the serialized queue behind it; kill the
// child so everything falls back to spawns and a fresh serve restarts.
this.pending.delete(id)
reject(new CliError('timeout', 'codeburn serve timed out'))
child.kill('SIGKILL')
}, timeoutMs)
this.pending.set(id, { resolve, reject, timer })
child.stdin!.write(JSON.stringify({ id, args }) + '\n', (err) => {
if (err) {
this.pending.delete(id)
clearTimeout(timer)
reject(new CliError('nonzero', 'serve write failed'))
}
})
})
}
destroy(): void {
this.deaths = SERVE_MAX_RESTARTS
this.child?.kill('SIGKILL')
this.onDeath()
}
}
let serveClient: ServeClient | null = null
/** Start the resident serve child and its warm-up query. Called once from app
* startup (never from the spawn path, so unit tests of the scheduler and the
* cold-start flow are byte-identical without it). Safe to call repeatedly. */
export function startServeWarmup(): void {
const target = resolveTarget()
if (!target) return
if (serveClient?.disabled()) return
if (!serveClient) serveClient = new ServeClient(spawnSpecFor(target, ['serve', '--stdio']))
serveClient.start()
}
export function spawnCli(
args: string[],
opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority } = {},
): Promise<unknown> {
const target = resolveTarget()
if (!target) return Promise.reject(new CliError('not-found', 'codeburn CLI not found', notFoundStage()))
const spec = spawnSpecFor(target, args)
if (opts.extraEnv) spec.env = { ...spec.env, ...opts.extraEnv }
const key = JSON.stringify([spec.bin, ...spec.args])
const cached = readCache.get(key)
if (cached && Date.now() - cached.at < COALESCE_TTL_MS) return Promise.resolve(cached.value)
const existing = readInflight.get(key)
// A same-cadence re-poll during a slow cold warmup coalesces onto the one
// in-flight child (which already carries onStderr); no second cold parse.
// Coalesce/cache hits settle here, BEFORE queueing, so they never hold a slot.
if (existing) return existing
// Serve fast-path: warm resident child answers the panel query without a
// spawn. The child is started once at app startup (startServeWarmup); until
// it is warm, every call keeps the plain spawn path.
if (SERVE_ROUTED.has(args[0] ?? '') && !opts.extraEnv) {
const serve = serveClient
if (serve?.isWarmAndReady()) {
const flight = serve.request(args, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
.catch(() => runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr))
.then(value => { readCache.set(key, { at: Date.now(), value }); return value })
.finally(() => { readInflight.delete(key) })
readInflight.set(key, flight)
return flight
}
}
const priority = opts.priority ?? 'interactive'
const flight = (async () => {
await acquireSlot(priority)
try {
return await runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr)
} finally {
releaseSlot()
}
})()
.then(value => { readCache.set(key, { at: Date.now(), value }); return value })
.finally(() => { readInflight.delete(key) })
readInflight.set(key, flight)
return flight
}
/** Spawn a config-mutating CLI command and return its text output verbatim.
* Mutations count as interactive, so they take a run slot ahead of any queued
* background warm — a Settings save is never stuck behind speculative prefetch. */
export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {}): Promise<ActionResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const target = resolveTarget()
if (!target) return Promise.resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null })
const spec = spawnSpecFor(target, args)
return (async () => {
try {
await acquireSlot('interactive')
} catch {
// Slot grant was cancelled (killAll during quit); never reached a spawn.
return { ok: false, stdout: '', stderr: 'codeburn cancelled', code: null }
}
try {
return await runAction(spec, args, timeoutMs)
} finally {
releaseSlot()
}
})()
}
function runAction(spec: SpawnSpec, args: string[], timeoutMs: number): Promise<ActionResult> {
return new Promise<ActionResult>(resolve => {
const child = spawn(spec.bin, spec.args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spec.env })
activeChildren.add(child)
let stdout = ''
let stderr = ''
let settled = false
const finish = (result: ActionResult) => {
if (settled) return
settled = true
clearTimeout(timer)
activeChildren.delete(child)
// The action may have changed config the read cache still reflects; a
// Settings refetch fires immediately after, so serve it fresh data.
readCache.clear()
resolve(result)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish({ ok: false, stdout, stderr: `codeburn ${args[0] ?? ''} timed out after ${timeoutMs}ms`, code: null })
}, timeoutMs)
child.stdout.on('data', chunk => { stdout += chunk })
child.stderr.on('data', chunk => { stderr += chunk })
child.on('error', err => finish({ ok: false, stdout, stderr: err.message, code: null }))
child.on('close', code => finish({ ok: code === 0, stdout, stderr, code }))
})
}