Loading files
8035964e1This file is pinned to the selected release. Download the original to open it in your design tool.
/** Disposable one-shot processes used by device recaps and voice routing. */
import { spawn, type ChildProcess } from 'child_process'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { chmod, copyFile, mkdtemp, readFile, readdir, rm, unlink } from 'fs/promises'
import { dirname, join } from 'path'
import { randomUUID } from 'crypto'
import { homedir, tmpdir, userInfo } from 'os'
import { env } from '../config/env.js'
import { findCursorTranscript } from '../engines/cursor/discovery.js'
import { cursorRuntimeBin, opencodeBin } from './engineBin.js'
import {
DisposableOneShotPool,
type ActiveEngineCounts,
type DisposableWorker,
type OneShotEngine,
} from './disposableOneShotPool.js'
import { oneShotParentEnv } from './loginShellEnv.js'
import { scrubTerminalContext } from './terminalEnvironment.js'
function claudeBin(): string {
return env.CLAUDE_PATH || 'claude'
}
/**
* Add one directory to devin's trusted-workspace list, if it is not already there.
*
* Devin 3000.3.22 refuses to run anywhere it has not been trusted interactively:
*
* Error: Refusing to run in an untrusted workspace: <dir>
* Start `devin` interactively in this directory to trust it, or set
* `respect_workspace_trust: false` in your config to restore the previous behavior.
*
* The recap is headless and runs in a scratch dir machine just created, so there is no prompt for anyone
* to answer and no session that survives it — hence this narrow exception to "harness edits no config":
* ONE path is appended, the one being created here. The config-wide `respect_workspace_trust: false`
* would switch the check off for the user's real projects too; every other entry, and the setting
* itself, is left alone. The interactive vendor CLI does NOT use this — its pane can ask.
*/
export function trustDevinWorkspace(dir: string, onError?: (message: string) => void): void {
const file = join(env.DEVIN_HOME, 'trusted_workspaces.json')
let config: { trusted_paths?: unknown } = {}
try {
const parsed: unknown = JSON.parse(readFileSync(file, 'utf-8'))
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed
} catch { /* absent or unreadable — a fresh list below is the right answer either way */ }
const paths = Array.isArray(config.trusted_paths)
? config.trusted_paths.filter((p): p is string => typeof p === 'string')
: []
if (paths.includes(dir)) return
try {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, `${JSON.stringify({ ...config, trusted_paths: [...paths, dir] }, null, 2)}\n`)
} catch (err) {
// Never fatal: devin then refuses with its own message, which is clearer than anything said here.
onError?.(err instanceof Error ? err.message : String(err))
}
}
function cursorBin(): string {
const bin = cursorRuntimeBin()
if (bin) return bin
throw new Error('Cursor CLI command is ambiguous or unavailable; install cursor-agent or set CURSOR_PATH to its absolute path')
}
function buildEnv(): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH || '',
HOME: process.env.HOME || homedir(),
USER: process.env.USER || userInfo().username,
LOGNAME: process.env.LOGNAME || process.env.USER || userInfo().username,
TMPDIR: tmpdir(),
LANG: process.env.LANG || 'en_US.UTF-8',
TERM: process.env.TERM || 'xterm-256color',
NODE_ENV: process.env.NODE_ENV || 'production',
...(process.env.ANTHROPIC_BASE_URL && { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL }),
...(process.env.ANTHROPIC_AUTH_TOKEN && { ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN }),
}
}
export interface OneShotOptions {
prompt: string
model?: string
effort?: 'low' | 'medium' | 'high'
cwd: string
timeoutMs?: number
signal?: AbortSignal
}
type OneShotResult = { text: string; sessionId: string | null }
function abortError(engine: OneShotEngine): Error {
return Object.assign(new Error(`${engine} one-shot aborted`), { name: 'AbortError' })
}
function killGroup(child: ChildProcess, signal: NodeJS.Signals = 'SIGKILL'): void {
if (child.pid == null || child.exitCode != null) return
try { process.kill(-child.pid, signal) } catch {
try { child.kill(signal) } catch { /* already gone */ }
}
}
export async function cleanupCursorOneShotSession(sessionId: string): Promise<void> {
const transcript = await findCursorTranscript(env.CURSOR_HOME, sessionId)
if (transcript) await rm(dirname(transcript), { recursive: true, force: true }).catch(() => {})
const chatsRoot = join(env.CURSOR_HOME, 'chats')
const workspaces = await readdir(chatsRoot, { withFileTypes: true }).catch(() => [])
await Promise.all(workspaces
.filter((entry) => entry.isDirectory())
.map((entry) => rm(join(chatsRoot, entry.name, sessionId), { recursive: true, force: true }).catch(() => {})))
}
abstract class ProcessWorker implements DisposableWorker<OneShotOptions, OneShotResult> {
readonly createdAt = Date.now()
protected assigned = false
protected readonly child: ChildProcess
private readonly exitListeners = new Set<() => void>()
private readonly spawned: Promise<void>
abstract readonly engine: OneShotEngine
abstract run(options: OneShotOptions): Promise<OneShotResult>
constructor(child: ChildProcess) {
this.child = child
this.spawned = new Promise((resolve, reject) => {
child.once('spawn', () => resolve())
child.once('error', reject)
})
child.once('close', () => {
for (const listener of this.exitListeners) listener()
this.exitListeners.clear()
})
}
ready(): Promise<this> {
return this.spawned.then(() => this)
}
isAlive(): boolean {
return this.child.exitCode == null && !this.child.killed
}
onExit(listener: () => void): void {
this.exitListeners.add(listener)
}
dispose(): void {
killGroup(this.child)
}
}
class ClaudeWorker extends ProcessWorker {
readonly engine = 'claude' as const
private buffer = ''
private stderr = ''
private sessionId: string | null = null
private resultText = ''
private readonly assistantParts: string[] = []
private pending: {
resolve: (result: OneShotResult) => void
reject: (err: unknown) => void
timer: NodeJS.Timeout
signal?: AbortSignal
onAbort: () => void
} | null = null
constructor(cwd: string, model?: string, effort?: OneShotOptions['effort']) {
const args = [
'--print', '--verbose',
'--input-format', 'stream-json',
'--output-format', 'stream-json',
'--no-session-persistence',
// Recap is pure text condensation. Keep Claude from loading project/user customizations,
// slash-command skills, MCP servers, or built-in tools.
'--safe-mode',
'--disable-slash-commands',
'--tools', '',
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
...(model ? ['--model', model] : []),
...(effort ? ['--effort', effort] : []),
]
const child = spawn(claudeBin(), args, {
cwd, env: buildEnv(), detached: true, stdio: ['pipe', 'pipe', 'pipe'],
})
super(child)
child.stdout?.on('data', (chunk: Buffer) => this.onStdout(chunk.toString()))
child.stderr?.on('data', (chunk: Buffer) => { this.stderr += chunk.toString() })
child.stdin?.on('error', (err) => this.fail(err))
child.on('error', (err) => this.fail(err))
child.on('close', (code) => {
if (this.buffer.trim()) this.handleLine(this.buffer)
if (!this.pending) return
const text = (this.resultText || this.assistantParts.join('')).trim()
if (!text && code !== 0) this.fail(new Error(`claude one-shot exited ${code}: ${this.stderr.slice(0, 500)}`))
else this.complete(text)
})
}
run(options: OneShotOptions): Promise<OneShotResult> {
if (this.assigned) return Promise.reject(new Error('claude recap worker already consumed'))
this.assigned = true
if (options.signal?.aborted) return Promise.reject(abortError(this.engine))
const timeoutMs = options.timeoutMs ?? 60_000
return new Promise((resolve, reject) => {
const onAbort = (): void => { this.fail(abortError(this.engine)); this.dispose() }
const timer = setTimeout(() => {
this.fail(new Error(`claude one-shot timed out after ${timeoutMs}ms`))
this.dispose()
}, timeoutMs)
this.pending = { resolve, reject, timer, signal: options.signal, onAbort }
options.signal?.addEventListener('abort', onAbort)
if (!this.child.stdin || this.child.stdin.destroyed || !this.isAlive()) {
this.fail(new Error('claude recap worker is not writable'))
return
}
try {
this.child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: options.prompt } }) + '\n')
} catch (err) {
this.fail(err)
}
})
}
private onStdout(chunk: string): void {
this.buffer += chunk
const lines = this.buffer.split('\n')
this.buffer = lines.pop() ?? ''
for (const line of lines) this.handleLine(line)
}
private handleLine(line: string): void {
const trimmed = line.trim()
if (!trimmed) return
let evt: Record<string, unknown>
try { evt = JSON.parse(trimmed) } catch { return }
if (typeof evt.session_id === 'string' && !this.sessionId) this.sessionId = evt.session_id
if (evt.type === 'assistant') {
const content = (evt.message as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content
if (Array.isArray(content)) {
const text = content.filter((part) => part?.type === 'text').map((part) => part.text ?? '').join('')
if (text) this.assistantParts.push(text)
}
}
if (evt.type === 'result') {
if (typeof evt.result === 'string') this.resultText = evt.result
this.complete((this.resultText || this.assistantParts.join('')).trim())
}
}
private cleanupPending(): typeof this.pending {
const pending = this.pending
if (!pending) return null
this.pending = null
clearTimeout(pending.timer)
pending.signal?.removeEventListener('abort', pending.onAbort)
return pending
}
private complete(text: string): void {
const pending = this.cleanupPending()
pending?.resolve({ text, sessionId: this.sessionId })
}
private fail(err: unknown): void {
this.cleanupPending()?.reject(err)
}
}
class CodexWorker extends ProcessWorker {
readonly engine = 'codex' as const
private stderr = ''
private readonly output: string
private pending: {
resolve: (result: OneShotResult) => void
reject: (err: unknown) => void
timer: NodeJS.Timeout
signal?: AbortSignal
onAbort: () => void
} | null = null
constructor(cwd: string, model?: string, effort?: OneShotOptions['effort']) {
const output = join(cwd, `.codex-recap-${randomUUID()}.txt`)
const args = [
'exec', '--json', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check',
'--ignore-user-config', '--ignore-rules', '--output-last-message', output,
...(model ? ['--model', model] : []),
...(effort ? ['-c', `model_reasoning_effort="${effort}"`] : []),
'-',
]
const processEnv = scrubTerminalContext({ ...oneShotParentEnv() })
const child = spawn(process.env.CODEX_PATH || 'codex', args, {
cwd, env: processEnv, detached: true, stdio: ['pipe', 'ignore', 'pipe'],
})
super(child)
this.output = output
child.stderr?.on('data', (chunk: Buffer) => { this.stderr += chunk.toString() })
child.stdin?.on('error', (err) => this.fail(err))
child.on('error', (err) => this.fail(err))
child.on('close', (code) => { void this.onClose(code) })
}
override dispose(): void {
super.dispose()
void unlink(this.output).catch(() => {})
}
run(options: OneShotOptions): Promise<OneShotResult> {
if (this.assigned) return Promise.reject(new Error('codex recap worker already consumed'))
this.assigned = true
if (options.signal?.aborted) return Promise.reject(abortError(this.engine))
const timeoutMs = options.timeoutMs ?? 60_000
return new Promise((resolve, reject) => {
const onAbort = (): void => { this.fail(abortError(this.engine)); this.dispose() }
const timer = setTimeout(() => {
this.fail(new Error(`codex one-shot timed out after ${timeoutMs}ms`))
this.dispose()
}, timeoutMs)
this.pending = { resolve, reject, timer, signal: options.signal, onAbort }
options.signal?.addEventListener('abort', onAbort)
if (!this.child.stdin || this.child.stdin.destroyed || !this.isAlive()) {
this.fail(new Error('codex recap worker is not writable'))
return
}
try { this.child.stdin.end(options.prompt) } catch (err) { this.fail(err) }
})
}
private async onClose(code: number | null): Promise<void> {
if (!this.pending) return
const text = (await readFile(this.output, 'utf8').catch(() => '')).trim()
await unlink(this.output).catch(() => {})
if (!text && code !== 0) this.fail(new Error(`codex one-shot exited ${code}: ${this.stderr.slice(0, 500)}`))
else this.complete(text)
}
private cleanupPending(): typeof this.pending {
const pending = this.pending
if (!pending) return null
this.pending = null
clearTimeout(pending.timer)
pending.signal?.removeEventListener('abort', pending.onAbort)
return pending
}
private complete(text: string): void {
this.cleanupPending()?.resolve({ text, sessionId: null })
}
private fail(err: unknown): void {
this.cleanupPending()?.reject(err)
}
}
class CursorWorker extends ProcessWorker {
readonly engine = 'cursor' as const
private buffer = ''
private stderr = ''
private sessionId: string | null = null
private resultText = ''
private cleanupScheduled = false
private readonly assistantParts: string[] = []
private pending: {
resolve: (result: OneShotResult) => void
reject: (err: unknown) => void
timer: NodeJS.Timeout
signal?: AbortSignal
onAbort: () => void
} | null = null
constructor(cwd: string, model?: string) {
const args = [
'--print',
'--mode', 'ask',
'--sandbox', 'enabled',
'--trust',
'--output-format', 'stream-json',
...(model ? ['--model', model] : []),
]
const processEnv = scrubTerminalContext({ ...oneShotParentEnv() })
const child = spawn(cursorBin(), args, {
cwd, env: processEnv, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
})
super(child)
child.stdout?.on('data', (chunk: Buffer) => this.onStdout(chunk.toString()))
child.stderr?.on('data', (chunk: Buffer) => { this.stderr += chunk.toString() })
child.stdin?.on('error', (err) => this.fail(err))
child.on('error', (err) => this.fail(err))
child.on('close', (code) => {
if (this.buffer.trim()) this.handleLine(this.buffer)
if (!this.pending) return
const text = (this.resultText || this.assistantParts.join('')).trim()
if (!text && code !== 0) this.fail(new Error(`cursor one-shot exited ${code}: ${this.stderr.slice(0, 500)}`))
else this.complete(text)
})
}
run(options: OneShotOptions): Promise<OneShotResult> {
if (this.assigned) return Promise.reject(new Error('cursor recap worker already consumed'))
this.assigned = true
if (options.signal?.aborted) return Promise.reject(abortError(this.engine))
const timeoutMs = options.timeoutMs ?? 60_000
return new Promise((resolve, reject) => {
const onAbort = (): void => { this.fail(abortError(this.engine)); this.dispose() }
const timer = setTimeout(() => {
this.fail(new Error(`cursor one-shot timed out after ${timeoutMs}ms`))
this.dispose()
}, timeoutMs)
this.pending = { resolve, reject, timer, signal: options.signal, onAbort }
options.signal?.addEventListener('abort', onAbort)
if (!this.child.stdin || this.child.stdin.destroyed || !this.isAlive()) {
this.fail(new Error('cursor recap worker is not writable'))
return
}
try { this.child.stdin.end(options.prompt) } catch (err) { this.fail(err) }
})
}
override dispose(): void {
super.dispose()
if (this.cleanupScheduled) return
this.cleanupScheduled = true
const cleanup = (): void => {
if (this.sessionId) void cleanupCursorOneShotSession(this.sessionId)
}
if (this.child.exitCode != null) cleanup()
else this.child.once('close', cleanup)
}
private onStdout(chunk: string): void {
this.buffer += chunk
const lines = this.buffer.split('\n')
this.buffer = lines.pop() ?? ''
for (const line of lines) this.handleLine(line)
}
private handleLine(line: string): void {
const trimmed = line.trim()
if (!trimmed) return
let evt: Record<string, unknown>
try { evt = JSON.parse(trimmed) } catch { return }
if (typeof evt.session_id === 'string' && !this.sessionId) this.sessionId = evt.session_id
if (evt.type === 'assistant') {
const content = (evt.message as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content
if (Array.isArray(content)) {
const text = content.filter((part) => part?.type === 'text').map((part) => part.text ?? '').join('')
if (text) this.assistantParts.push(text)
}
}
if (evt.type !== 'result') return
if (typeof evt.result === 'string') this.resultText = evt.result
if (evt.is_error === true || evt.subtype === 'error') {
this.fail(new Error(`cursor one-shot failed: ${(this.resultText || this.stderr).slice(0, 500)}`))
return
}
this.complete((this.resultText || this.assistantParts.join('')).trim())
}
private cleanupPending(): typeof this.pending {
const pending = this.pending
if (!pending) return null
this.pending = null
clearTimeout(pending.timer)
pending.signal?.removeEventListener('abort', pending.onAbort)
return pending
}
private complete(text: string): void {
this.cleanupPending()?.resolve({ text, sessionId: this.sessionId })
}
private fail(err: unknown): void {
this.cleanupPending()?.reject(err)
}
}
// Recap runs in an isolated OpenCode data dir so ephemeral summary sessions never land in the user's
// real opencode.db, while still reading their ~/.config/opencode provider/model config.
const OPENCODE_RECAP_DATA_DIR = join(env.ADAPTER_DATA_DIR, 'opencode-recap')
class OpencodeWorker extends ProcessWorker {
readonly engine = 'opencode' as const
private buffer = ''
private stderr = ''
private readonly assistantParts: string[] = []
private pending: {
resolve: (result: OneShotResult) => void
reject: (err: unknown) => void
timer: NodeJS.Timeout
signal?: AbortSignal
onAbort: () => void
} | null = null
constructor(model?: string) {
// `opencode run` reads the prompt from stdin (pipe → EOF), so the worker can be pre-warmed and fed
// the prompt later. `--pure` skips external plugins (so the machine discovery plugin never
// self-registers this ephemeral recap session). `--format json` streams `{type:'text',part:{text}}`.
mkdirSync(OPENCODE_RECAP_DATA_DIR, { recursive: true, mode: 0o700 })
const args = ['run', '--pure', '--format', 'json', ...(model ? ['--model', model] : [])]
const processEnv = scrubTerminalContext({ ...oneShotParentEnv() })
processEnv.OPENCODE_DATA_DIR = OPENCODE_RECAP_DATA_DIR
const child = spawn(opencodeBin(), args, {
cwd: OPENCODE_RECAP_DATA_DIR, env: processEnv, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
})
super(child)
child.stdout?.on('data', (chunk: Buffer) => this.onStdout(chunk.toString()))
child.stderr?.on('data', (chunk: Buffer) => { this.stderr += chunk.toString() })
child.stdin?.on('error', (err) => this.fail(err))
child.on('error', (err) => this.fail(err))
child.on('close', (code) => {
if (this.buffer.trim()) this.handleLine(this.buffer)
if (!this.pending) return
const text = this.assistantParts.join('').trim()
if (!text && code !== 0) this.fail(new Error(`opencode one-shot exited ${code}: ${this.stderr.slice(0, 500)}`))
else this.complete(text)
})
}
run(options: OneShotOptions): Promise<OneShotResult> {
if (this.assigned) return Promise.reject(new Error('opencode recap worker already consumed'))
this.assigned = true
if (options.signal?.aborted) return Promise.reject(abortError(this.engine))
const timeoutMs = options.timeoutMs ?? 60_000
return new Promise((resolve, reject) => {
const onAbort = (): void => { this.fail(abortError(this.engine)); this.dispose() }
const timer = setTimeout(() => {
this.fail(new Error(`opencode one-shot timed out after ${timeoutMs}ms`))
this.dispose()
}, timeoutMs)
this.pending = { resolve, reject, timer, signal: options.signal, onAbort }
options.signal?.addEventListener('abort', onAbort)
if (!this.child.stdin || this.child.stdin.destroyed || !this.isAlive()) {
this.fail(new Error('opencode recap worker is not writable'))
return
}
try { this.child.stdin.end(options.prompt) } catch (err) { this.fail(err) }
})
}
private onStdout(chunk: string): void {
this.buffer += chunk
const lines = this.buffer.split('\n')
this.buffer = lines.pop() ?? ''
for (const line of lines) this.handleLine(line)
}
private handleLine(line: string): void {
const trimmed = line.trim()
if (!trimmed) return
let evt: Record<string, unknown>
try { evt = JSON.parse(trimmed) } catch { return }
if (evt.type === 'text') {
const part = evt.part as { text?: string } | undefined
if (part && typeof part.text === 'string') this.assistantParts.push(part.text)
}
}
private cleanupPending(): typeof this.pending {
const pending = this.pending
if (!pending) return null
this.pending = null
clearTimeout(pending.timer)
pending.signal?.removeEventListener('abort', pending.onAbort)
return pending
}
private complete(text: string): void {
this.cleanupPending()?.resolve({ text, sessionId: null })
}
private fail(err: unknown): void {
this.cleanupPending()?.reject(err)
}
}
function kiloBin(): string {
return env.KILO_PATH || 'kilo'
}
/**
* Recap runs in an isolated Kilo data dir so ephemeral summary sessions never land in the user's real
* kilo.db, while still reading their ~/.config/kilo provider/model config.
*
* The isolation mechanism is where kilo parts company with the opencode it forked. Opencode honours
* `OPENCODE_DATA_DIR`; kilo has no equivalent — measured with `kilo debug paths`, it ignores both
* `KILO_DATA_DIR` and `OPENCODE_DATA_DIR` and moves its store only for `XDG_DATA_HOME`. So the child is
* given that instead, and because kilo appends its own name, the store lands at `<dir>/kilo/kilo.db`.
*
* `XDG_CONFIG_HOME` is deliberately NOT set: it would move the config root too and the recap would lose
* the user's provider and model, which is the one thing this worker has to inherit.
*
* First spawn into a fresh dir prints `Performing one time database migration` and pays for it; the pool
* pre-warms workers, so that cost lands before a recap is ever asked for.
*/
const KILO_RECAP_DATA_DIR = join(env.ADAPTER_DATA_DIR, 'kilo-recap')
/**
* The argv + env for one kilo recap worker. Exported so the containment rules below are testable — a
* spawn is not, and both of these have already failed in production once.
*/
export function kiloOneShotSpawn(
model: string | undefined,
parentEnv: NodeJS.ProcessEnv,
dataDir: string = KILO_RECAP_DATA_DIR,
): { args: string[]; env: NodeJS.ProcessEnv } {
const args = [
'run', '--pure', '--auto', '--format', 'json', '--dir', dataDir,
...(model ? ['--model', model] : []),
]
const childEnv = scrubTerminalContext({ ...parentEnv })
childEnv.XDG_DATA_HOME = dataDir
// Belt and braces with `--dir`. `spawn({cwd})` does NOT rewrite `PWD`, and kilo resolves its project
// from that variable, so without this the child inherits the DAEMON's directory.
childEnv.PWD = dataDir
return { args, env: childEnv }
}
class KiloWorker extends ProcessWorker {
readonly engine = 'kilo' as const
private buffer = ''
private stderr = ''
private readonly assistantParts: string[] = []
private pending: {
resolve: (result: OneShotResult) => void
reject: (err: unknown) => void
timer: NodeJS.Timeout
signal?: AbortSignal
onAbort: () => void
} | null = null
constructor(model?: string) {
// `kilo run` reads the prompt from stdin (pipe → EOF), so the worker can be pre-warmed and fed the
// prompt later. `--pure` skips external plugins (so the discovery plugin never self-registers this
// ephemeral recap session). `--format json` streams one JSON envelope per line.
//
// `--auto` is REQUIRED for this disposable worker only. Measured:
// without it a recap run whose model reaches for any tool dies outright — kilo auto-rejects the
// permission and ends the run (`run ended with an auto-rejected permission; pass --auto for
// autonomous use`), emitting an `error` envelope and NO text. A summariser has no user to ask, and a
// recap that returns nothing is the failure this flag prevents. Note the asymmetry that makes this
// easy to get wrong: `--auto` is a valid flag of the `run` SUBCOMMAND, while the bare TUI rejects it
// — the interactive TUI remains entirely under the user's own permission configuration.
mkdirSync(KILO_RECAP_DATA_DIR, { recursive: true, mode: 0o700 })
// `--dir` pins the workspace explicitly. Do NOT rely on the spawn `cwd` alone: kilo resolves its
// project from `$PWD`, and `spawn({cwd})` does not rewrite that variable — the child inherits the
// DAEMON's `PWD`. Measured in production: the worker logged
// `kilocode-indexing workspacePath=<the daemon's launch directory> initializing project indexing`
// the moment a prompt arrived, i.e. every recap ran against the user's real repository instead of an
// empty scratch dir. It then hung indexing and gathering context until the 60s timeout, so no
// `turn_summary` was ever emitted and the device tile stayed on `processing` forever.
const { args, env: processEnv } = kiloOneShotSpawn(model, oneShotParentEnv())
const child = spawn(kiloBin(), args, {
cwd: KILO_RECAP_DATA_DIR, env: processEnv, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
})
super(child)
child.stdout?.on('data', (chunk: Buffer) => this.onStdout(chunk.toString()))
child.stderr?.on('data', (chunk: Buffer) => { this.stderr += chunk.toString() })
child.stdin?.on('error', (err) => this.fail(err))
child.on('error', (err) => this.fail(err))
child.on('close', (code) => {
if (this.buffer.trim()) this.handleLine(this.buffer)
if (!this.pending) return
const text = this.assistantParts.join('').trim()
if (!text && code !== 0) this.fail(new Error(`kilo one-shot exited ${code}: ${this.stderr.slice(0, 500)}`))
else this.complete(text)
})
}
run(options: OneShotOptions): Promise<OneShotResult> {
if (this.assigned) return Promise.reject(new Error('kilo recap worker already consumed'))
this.assigned = true
if (options.signal?.aborted) return Promise.reject(abortError(this.engine))
const timeoutMs = options.timeoutMs ?? 60_000
return new Promise((resolve, reject) => {
const onAbort = (): void => { this.fail(abortError(this.engine)); this.dispose() }
const timer = setTimeout(() => {
this.fail(new Error(`kilo one-shot timed out after ${timeoutMs}ms`))
this.dispose()
}, timeoutMs)
this.pending = { resolve, reject, timer, signal: options.signal, onAbort }
options.signal?.addEventListener('abort', onAbort)
if (!this.child.stdin || this.child.stdin.destroyed || !this.isAlive()) {
this.fail(new Error('kilo recap worker is not writable'))
return
}
try { this.child.stdin.end(options.prompt) } catch (err) { this.fail(err) }
})
}
private onStdout(chunk: string): void {
this.buffer += chunk
const lines = this.buffer.split('\n')
this.buffer = lines.pop() ?? ''
for (const line of lines) this.handleLine(line)
}
private handleLine(line: string): void {
const trimmed = line.trim()
if (!trimmed) return
let evt: Record<string, unknown>
try { evt = JSON.parse(trimmed) } catch { return }
if (evt.type === 'text') {
const part = evt.part as { text?: string } | undefined
if (part && typeof part.text === 'string') this.assistantParts.push(part.text)
}
}
private cleanupPending(): typeof this.pending {
const pending = this.pending
if (!pending) return null
this.pending = null
clearTimeout(pending.timer)
pending.signal?.removeEventListener('abort', pending.onAbort)
return pending
}
private complete(text: string): void {
this.cleanupPending()?.resolve({ text, sessionId: null })
}
private fail(err: unknown): void {
this.cleanupPending()?.reject(err)
}
}
function piBin(): string {
return env.PI_PATH || 'pi'
}
class PiWorker extends ProcessWorker {
readonly engine = 'pi' as const
private stdout = ''
private stderr = ''
private pending: {
resolve: (result: OneShotResult) => void
reject: (err: unknown) => void
timer: NodeJS.Timeout
signal?: AbortSignal
onAbort: () => void
} | null = null
constructor(cwd: string, model?: string) {
// `pi -p` reads the prompt from piped stdin (merged into the initial prompt) and prints the plain
// answer, so the worker can be pre-warmed and fed later. `--no-session` keeps the recap out of the
// user's session store; `--no-extensions` stops the machine discovery extension from registering it.
const args = ['-p', '--no-session', '--no-extensions', ...(model ? ['--model', model] : [])]
const processEnv = scrubTerminalContext({ ...oneShotParentEnv() })
const child = spawn(piBin(), args, {
cwd, env: processEnv, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
})
super(child)
child.stdout?.on('data', (chunk: Buffer) => { this.stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { this.stderr += chunk.toString() })
child.stdin?.on('error', (err) => this.fail(err))
child.on('error', (err) => this.fail(err))
child.on('close', (code) => {
if (!this.pending) return
const text = this.stdout.trim()
if (!text && code !== 0) this.fail(new Error(`pi one-shot exited ${code}: ${this.stderr.slice(0, 500)}`))
else this.complete(text)
})
}
run(options: OneShotOptions): Promise<OneShotResult> {
if (this.assigned) return Promise.reject(new Error('pi recap worker already consumed'))
this.assigned = true
if (options.signal?.aborted) return Promise.reject(abortError(this.engine))
const timeoutMs = options.timeoutMs ?? 60_000
return new Promise((resolve, reject) => {
const onAbort = (): void => { this.fail(abortError(this.engine)); this.dispose() }
const timer = setTimeout(() => {
this.fail(new Error(`pi one-shot timed out after ${timeoutMs}ms`))
this.dispose()
}, timeoutMs)
this.pending = { resolve, reject, timer, signal: options.signal, onAbort }
options.signal?.addEventListener('abort', onAbort)
if (!this.child.stdin || this.child.stdin.destroyed || !this.isAlive()) {
this.fail(new Error('pi recap worker is not writable'))
return
}
try { this.child.stdin.end(options.prompt) } catch (err) { this.fail(err) }
})
}
private cleanupPending(): typeof this.pending {
const pending = this.pending
if (!pending) return null
this.pending = null
clearTimeout(pending.timer)
pending.signal?.removeEventListener('abort', pending.onAbort)
return pending
}
private complete(text: string): void {
this.cleanupPending()?.resolve({ text, sessionId: null })
}
private fail(err: unknown): void {
this.cleanupPending()?.reject(err)
}
}
function commandCodeBin(): string {
return env.COMMANDCODE_PATH || 'commandcode'
}
class CommandCodeWorker extends ProcessWorker {
readonly engine = 'commandcode' as const
private stdout = ''
private stderr = ''
private pending: {
resolve: (result: OneShotResult) => void
reject: (err: unknown) => void
timer: NodeJS.Timeout
signal?: AbortSignal
onAbort: () => void
} | null = null
constructor(cwd: string, model?: string) {
// `commandcode -p` reads the prompt from piped stdin (verified), so the worker pre-warms and is fed
// later. `--no-session` keeps the recap out of the user's project/session list — without it every
// recap shows up as a session. There is no --no-hooks flag; the scrubbed terminal context below
// stops our own SessionStart hook registering this process.
const args = ['-p', '--no-session', ...(model ? ['-m', model] : [])]
const processEnv = scrubTerminalContext({ ...oneShotParentEnv() })
const child = spawn(commandCodeBin(), args, {
cwd, env: processEnv, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
})
super(child)
child.stdout?.on('data', (chunk: Buffer) => { this.stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { this.stderr += chunk.toString() })
child.stdin?.on('error', (err) => this.fail(err))
child.on('error', (err) => this.fail(err))
child.on('close', (code) => {
if (!this.pending) return
const text = this.stdout.trim()
if (!text && code !== 0) this.fail(new Error(`commandcode one-shot exited ${code}: ${this.stderr.slice(0, 500)}`))
else this.complete(text)
})
}
run(options: OneShotOptions): Promise<OneShotResult> {
if (this.assigned) return Promise.reject(new Error('commandcode recap worker already consumed'))
this.assigned = true
if (options.signal?.aborted) return Promise.reject(abortError(this.engine))
const timeoutMs = options.timeoutMs ?? 60_000
return new Promise((resolve, reject) => {
const onAbort = (): void => { this.fail(abortError(this.engine)); this.dispose() }
const timer = setTimeout(() => {
this.fail(new Error(`commandcode one-shot timed out after ${timeoutMs}ms`))
this.dispose()
}, timeoutMs)
this.pending = { resolve, reject, timer, signal: options.signal, onAbort }
options.signal?.addEventListener('abort', onAbort)
if (!this.child.stdin || this.child.stdin.destroyed || !this.isAlive()) {
this.fail(new Error('commandcode recap worker is not writable'))
return
}
try { this.child.stdin.end(options.prompt) } catch (err) { this.fail(err) }
})
}
private cleanupPending(): typeof this.pending {
const pending = this.pending
if (!pending) return null
this.pending = null
clearTimeout(pending.timer)
pending.signal?.removeEventListener('abort', pending.onAbort)
return pending
}
private complete(text: string): void {
this.cleanupPending()?.resolve({ text, sessionId: null })
}
private fail(err: unknown): void {
this.cleanupPending()?.reject(err)
}
}
let config: {
cwd: string
claudeModel?: string
codexModel?: string
cursorModel?: string
opencodeModel?: string
kiloModel?: string
piModel?: string
commandcodeModel?: string
effort?: OneShotOptions['effort']
} | null = null
const pool = new DisposableOneShotPool<OneShotOptions, OneShotResult>(async (engine) => {
if (!config) throw new Error('recap pool is not configured')
if (engine === 'claude') return new ClaudeWorker(config.cwd, config.claudeModel, config.effort).ready()
if (engine === 'codex') return new CodexWorker(config.cwd, config.codexModel, config.effort).ready()
if (engine === 'cursor') return new CursorWorker(config.cwd, config.cursorModel).ready()
if (engine === 'pi') return new PiWorker(config.cwd, config.piModel).ready()
if (engine === 'commandcode') return new CommandCodeWorker(config.cwd, config.commandcodeModel).ready()
if (engine === 'kilo') return new KiloWorker(config.kiloModel).ready()
return new OpencodeWorker(config.opencodeModel).ready()
})
export function configureOneShotPool(next: {
cwd: string
claudeModel?: string
codexModel?: string
cursorModel?: string
opencodeModel?: string
kiloModel?: string
piModel?: string
commandcodeModel?: string
effort?: OneShotOptions['effort']
}): void {
const changed = config != null && (
config.cwd !== next.cwd || config.claudeModel !== next.claudeModel ||
config.codexModel !== next.codexModel || config.cursorModel !== next.cursorModel ||
config.opencodeModel !== next.opencodeModel || config.kiloModel !== next.kiloModel ||
config.piModel !== next.piModel ||
config.commandcodeModel !== next.commandcodeModel || config.effort !== next.effort
)
config = next
if (changed) pool.recycleReady()
}
export function setOneShotPoolActiveCounts(counts: ActiveEngineCounts): void {
pool.setActiveCounts(counts)
}
export function setOneShotPoolDeviceConnected(connected: boolean): void {
pool.setDeviceConnected(connected)
}
export function shutdownOneShotPool(): void {
pool.shutdown()
}
/** Spawn ONE worker for an engine. Shared by the cold path and both warm pools so a pooled worker can
* never be started differently from a direct one. */
function createOneShotWorker(
engine: OneShotEngine,
cwd: string,
model?: string,
effort?: OneShotOptions['effort'],
): Promise<DisposableWorker<OneShotOptions, OneShotResult>> {
return engine === 'claude'
? new ClaudeWorker(cwd, model, effort).ready()
: engine === 'codex'
? new CodexWorker(cwd, model, effort).ready()
: engine === 'cursor'
? new CursorWorker(cwd, model).ready()
: engine === 'pi'
? new PiWorker(cwd, model).ready()
: engine === 'commandcode'
? new CommandCodeWorker(cwd, model).ready()
: engine === 'kilo'
? new KiloWorker(model).ready()
: new OpencodeWorker(model).ready()
}
async function runDirect(engine: OneShotEngine, opts: OneShotOptions): Promise<OneShotResult> {
const worker = await createOneShotWorker(engine, opts.cwd, opts.model, opts.effort)
try { return await worker.run(opts) } finally { worker.dispose() }
}
export function runClaudeOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(abortError('claude'))
if (!config || config.cwd !== opts.cwd || config.claudeModel !== opts.model || config.effort !== opts.effort) {
return runDirect('claude', opts)
}
return pool.run('claude', opts)
}
export function runCodexOneShot(opts: OneShotOptions): Promise<{ text: string; sessionId: null }> {
if (opts.signal?.aborted) return Promise.reject(abortError('codex'))
const run = !config || config.cwd !== opts.cwd || config.codexModel !== opts.model || config.effort !== opts.effort
? runDirect('codex', opts)
: pool.run('codex', opts)
return run.then((result) => ({ text: result.text, sessionId: null }))
}
export function runCursorOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(abortError('cursor'))
if (!config || config.cwd !== opts.cwd || config.cursorModel !== opts.model) {
return runDirect('cursor', opts)
}
return pool.run('cursor', opts)
}
/** The measured Grok 1.0.0 headless invocation. A fresh GROK_HOME contains every persisted session. */
export function grokOneShotSpawn(
opts: Pick<OneShotOptions, 'prompt' | 'model' | 'effort' | 'cwd'>,
scratchHome: string,
parentEnv: NodeJS.ProcessEnv = oneShotParentEnv(),
): { args: string[]; env: NodeJS.ProcessEnv } {
const args = [
'--cwd', opts.cwd,
'--always-approve', '--no-memory', '--no-plan', '--max-turns', '1',
'--output-format', 'json',
...(opts.model ? ['--model', opts.model] : []),
...(opts.effort ? ['--reasoning-effort', opts.effort] : []),
'-p', opts.prompt,
]
const childEnv: NodeJS.ProcessEnv = scrubTerminalContext({ ...parentEnv, GROK_HOME: scratchHome })
delete childEnv.MACHINE_ID
return { args, env: childEnv }
}
function grokOutputText(stdout: string): string {
const candidates = [stdout.trim(), ...stdout.trim().split('\n').reverse()]
for (const candidate of candidates) {
if (!candidate) continue
try {
const parsed = JSON.parse(candidate) as { text?: unknown; result?: unknown }
if (typeof parsed.text === 'string') return parsed.text.trim()
if (typeof parsed.result === 'string') return parsed.result.trim()
} catch { /* try the next complete JSON value */ }
}
return ''
}
/** Grok takes its print prompt on argv, so it cannot be pre-warmed. Its real CLI always persists a
* session; isolating GROK_HOME and deleting it afterwards is the no-pollution equivalent. */
export async function runGrokOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) throw Object.assign(new Error('grok one-shot aborted'), { name: 'AbortError' })
mkdirSync(opts.cwd, { recursive: true })
const scratchHome = await mkdtemp(join(opts.cwd, '.grok-recap-'))
try {
// Copy auth/config into the short-lived home. A symlink would keep session writes isolated but still
// let a token refresh or config migration mutate the user's real files through the link.
for (const name of ['auth.json', 'config.toml']) {
const source = join(env.GROK_HOME, name)
if (!existsSync(source)) continue
const target = join(scratchHome, name)
await copyFile(source, target)
await chmod(target, 0o600)
}
const { args, env: processEnv } = grokOneShotSpawn(opts, scratchHome)
const timeoutMs = opts.timeoutMs ?? 60_000
return await new Promise<OneShotResult>((resolve, reject) => {
const child = spawn(env.GROK_PATH || 'grok', args, {
cwd: opts.cwd, env: processEnv, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
fn()
}
const kill = (): void => killGroup(child)
const onAbort = (): void => {
finish(() => reject(Object.assign(new Error('grok one-shot aborted'), { name: 'AbortError' })))
kill()
}
const timer = setTimeout(() => {
finish(() => reject(new Error(`grok one-shot timed out after ${timeoutMs}ms`)))
kill()
}, timeoutMs)
opts.signal?.addEventListener('abort', onAbort)
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', (err) => finish(() => reject(err)))
child.on('close', (code) => {
const text = grokOutputText(stdout)
finish(() => {
if (!text) reject(new Error(`grok one-shot exited ${code}: ${stderr.slice(0, 500)}`))
else resolve({ text, sessionId: null })
})
})
})
} finally {
await rm(scratchHome, { recursive: true, force: true }).catch(() => {})
}
}
/**
* The measured Antigravity 1.1.14 headless invocation.
*
* `-p` takes the prompt on argv, so this cannot be pre-warmed into the worker pool.
*
* Two deliberate omissions:
* - **No scratch data dir.** `ANTIGRAVITY_EXECUTABLE_DATA_DIR` looks like the override and is not one
* (measured: the run still wrote to the real `~/.gemini/antigravity-cli`), and a scratch `HOME` DOES
* isolate the store but takes the credentials with it — the re-auth prompt makes it unusable for a
* background worker. So the run lands in the user's store and is deleted afterwards by id.
* - **No workspace.** `-p` opens none, and agy refuses every tool without one ("no active workspace"),
* which is exactly the confinement a recap wants: it cannot read or write the user's files at all.
*/
export function agyOneShotSpawn(
opts: Pick<OneShotOptions, 'prompt' | 'model' | 'effort'>,
parentEnv: NodeJS.ProcessEnv = oneShotParentEnv(),
): { args: string[]; env: NodeJS.ProcessEnv } {
const args = [
'--output-format', 'json',
// A recap prompt is user text; without this a leading `/` would be expanded as a slash command.
'--disable-slash-commands',
...(opts.model ? ['--model', opts.model] : []),
...(opts.effort ? ['--effort', opts.effort] : []),
'-p', opts.prompt,
]
const childEnv = scrubTerminalContext({ ...parentEnv })
delete childEnv.MACHINE_ID
return { args, env: childEnv }
}
interface AgyOneShotOutput {
text: string
conversationId: string
}
/** agy prints one JSON object: `{conversation_id, status, response, duration_seconds, usage}`. */
export function agyOutputText(stdout: string): AgyOneShotOutput {
const raw = stdout.trim()
// The id is needed for cleanup even when the envelope does not parse, so read it directly too.
const idMatch = /"conversation_id"\s*:\s*"([0-9a-f-]{16,})"/i.exec(raw)
const conversationId = idMatch ? idMatch[1] : ''
for (const candidate of [raw, ...raw.split('\n').reverse()]) {
if (!candidate) continue
try {
const parsed = JSON.parse(candidate) as { response?: unknown; conversation_id?: unknown }
if (typeof parsed.response === 'string') {
return { text: parsed.response.trim(), conversationId: typeof parsed.conversation_id === 'string' ? parsed.conversation_id : conversationId }
}
} catch { /* try the next complete JSON value */ }
}
return { text: '', conversationId }
}
/**
* Remove the conversation a recap created.
*
* One `-p` run leaves exactly five artifacts, all named by the conversation id (measured):
* `brain/<id>/`, `conversations/<id>.db` plus its `-wal`/`-shm`, and `presence/<id>.lock`. Deleting by
* id touches nothing else, so a concurrent interactive agy session is never at risk.
*/
async function removeAgyConversation(conversationId: string): Promise<void> {
if (!/^[0-9a-f-]{16,}$/i.test(conversationId)) return
const targets = [
join(env.AGY_HOME, 'brain', conversationId),
join(env.AGY_HOME, 'conversations', `${conversationId}.db`),
join(env.AGY_HOME, 'conversations', `${conversationId}.db-wal`),
join(env.AGY_HOME, 'conversations', `${conversationId}.db-shm`),
join(env.AGY_HOME, 'presence', `${conversationId}.lock`),
]
for (const target of targets) await rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function runAgyOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) throw Object.assign(new Error('agy one-shot aborted'), { name: 'AbortError' })
const { args, env: processEnv } = agyOneShotSpawn(opts)
const timeoutMs = opts.timeoutMs ?? 60_000
let created = ''
try {
return await new Promise<OneShotResult>((resolve, reject) => {
const child = spawn(env.AGY_PATH || 'agy', args, {
cwd: opts.cwd, env: processEnv, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
fn()
}
const kill = (): void => killGroup(child)
const onAbort = (): void => {
created = created || agyOutputText(stdout).conversationId
finish(() => reject(Object.assign(new Error('agy one-shot aborted'), { name: 'AbortError' })))
kill()
}
const timer = setTimeout(() => {
created = created || agyOutputText(stdout).conversationId
finish(() => reject(new Error(`agy one-shot timed out after ${timeoutMs}ms`)))
kill()
}, timeoutMs)
opts.signal?.addEventListener('abort', onAbort)
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', (err) => finish(() => reject(err)))
child.on('close', (code) => {
const { text, conversationId } = agyOutputText(stdout)
created = conversationId
finish(() => {
if (!text) reject(new Error(`agy one-shot exited ${code}: ${stderr.slice(0, 500)}`))
else resolve({ text, sessionId: null })
})
})
})
} finally {
// An aborted run may still have created the conversation before it died, so this runs either way.
if (created) await removeAgyConversation(created)
}
}
/**
* The measured Copilot CLI 1.0.80 headless invocation.
*
* Three things settled by running it:
*
* - **`--no-color` makes stdout the answer and nothing else.** The "Changes / AI Credits / Tokens /
* Resume" block goes to stderr, so the recap needs no parsing at all — 12 bytes of stdout for a
* 12-byte reply.
* - **`--available-tools` with no values leaves the model no tools.** `-p` demands
* `--allow-all-tools`, which on its own would let a recap run shell commands; pairing the two gives
* a run that can answer but cannot touch anything.
* - **`--session-id` accepts a UUID we choose for a NEW session**, so the state this leaves behind is
* known before it exists and can be removed by name rather than by scraping stderr.
*/
export function copilotOneShotSpawn(
opts: Pick<OneShotOptions, 'prompt' | 'model'>,
sessionId: string,
parentEnv: NodeJS.ProcessEnv = oneShotParentEnv(),
): { args: string[]; env: NodeJS.ProcessEnv } {
const args = [
'-p', opts.prompt,
'--allow-all-tools',
'--available-tools',
'--no-color',
'--session-id', sessionId,
...(opts.model ? ['--model', opts.model] : []),
]
const childEnv = scrubTerminalContext({ ...parentEnv })
delete childEnv.MACHINE_ID
return { args, env: childEnv }
}
/**
* Remove the session a recap created.
*
* The event stream lives in a directory named by the id, so it goes cleanly. The row Copilot also
* writes into `session-store.db` is deliberately left alone: that database belongs to the user's
* running CLI, and deleting from it to tidy a recap is not worth writing into a live vendor store.
* The cost is an unnamed entry in `copilot --resume`.
*/
async function removeCopilotSession(sessionId: string): Promise<void> {
if (!/^[0-9a-f-]{16,}$/i.test(sessionId)) return
await rm(join(env.COPILOT_HOME, 'session-state', sessionId), { recursive: true, force: true }).catch(() => {})
}
export async function runCopilotOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) throw Object.assign(new Error('copilot one-shot aborted'), { name: 'AbortError' })
const sessionId = randomUUID()
const { args, env: processEnv } = copilotOneShotSpawn(opts, sessionId)
const timeoutMs = opts.timeoutMs ?? 60_000
try {
return await new Promise<OneShotResult>((resolve, reject) => {
const child = spawn(env.COPILOT_PATH || 'copilot', args, {
cwd: opts.cwd, env: processEnv, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
fn()
}
const kill = (): void => killGroup(child)
const onAbort = (): void => {
finish(() => reject(Object.assign(new Error('copilot one-shot aborted'), { name: 'AbortError' })))
kill()
}
const timer = setTimeout(() => {
finish(() => reject(new Error(`copilot one-shot timed out after ${timeoutMs}ms`)))
kill()
}, timeoutMs)
opts.signal?.addEventListener('abort', onAbort)
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', (err) => finish(() => reject(err)))
child.on('close', (code) => {
const text = stdout.trim()
finish(() => {
if (!text) reject(new Error(`copilot one-shot exited ${code}: ${stderr.slice(0, 500)}`))
else resolve({ text, sessionId: null })
})
})
})
} finally {
await removeCopilotSession(sessionId)
}
}
function hermesBin(): string {
return env.HERMES_PATH || 'hermes'
}
/**
* Hermes recap. Unlike every other engine, `hermes chat -q` takes the prompt as **argv, not stdin**, so
* a worker cannot be pre-warmed — each recap spawns a fresh process (Hermes is deliberately absent from
* `OneShotEngine`/the warm pool).
*
* Notably we do NOT pass `--safe-mode`: it implies `--ignore-user-config`, which would discard the
* user's model/provider (their whole reason for a working recap). Self-registration is prevented the
* same way every other worker does it: every terminal location variable is scrubbed. `--source tool`
* keeps it out of the user's session list.
*/
function museBin(): string {
return env.MUSE_PATH || 'muse'
}
/**
* One-shot recap through `muse exec`.
*
* Prompt goes as ARGV (muse has no stdin mode), so this worker cannot be pre-warmed the way a stdin-fed
* CLI can — it is deliberately outside the pool. `--json` is not used: the recap wants the answer, not
* the event stream. Terminal context is stripped so the worker cannot register itself as an agent, and
* MUSE_NO_AUTO_UPDATE keeps a background self-update from swapping the binary mid-run.
*/
export function runMuseOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(Object.assign(new Error('muse one-shot aborted'), { name: 'AbortError' }))
const args = ['exec', opts.prompt, ...(opts.model ? ['--model', opts.model] : [])]
const processEnv: NodeJS.ProcessEnv = scrubTerminalContext({ ...oneShotParentEnv(), MUSE_NO_AUTO_UPDATE: '1' })
const timeoutMs = opts.timeoutMs ?? 60_000
return new Promise<OneShotResult>((resolve, reject) => {
const child = spawn(museBin(), args, {
cwd: opts.cwd, env: processEnv, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
fn()
}
const kill = (): void => {
if (child.pid == null || child.exitCode != null) return
try { process.kill(-child.pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } }
}
const onAbort = (): void => {
finish(() => reject(Object.assign(new Error('muse one-shot aborted'), { name: 'AbortError' })))
kill()
}
const timer = setTimeout(() => {
finish(() => reject(new Error(`muse one-shot timed out after ${timeoutMs}ms`)))
kill()
}, timeoutMs)
opts.signal?.addEventListener('abort', onAbort)
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', (err) => finish(() => reject(err)))
child.on('close', (code) => {
// muse prints a `muse: workspace root: …` preamble before the answer — drop it.
const text = stdout.split('\n').filter((line) => !/^\s*muse:\s/.test(line)).join('\n').trim()
finish(() => {
if (!text && code !== 0) reject(new Error(`muse one-shot exited ${code}: ${stderr.slice(0, 500)}`))
else resolve({ text, sessionId: null })
})
})
})
}
/**
* One-shot recap through `amp -x`.
*
* The prompt goes as ARGV, so like muse this worker cannot be pre-warmed and stays outside the pool.
*
* `model` here is an agent MODE, not a model name: Amp exposes no model list and `-m low|medium|high|ultra`
* is what chooses one. `low` is both the cheapest and plenty for a one-sentence recap.
*
* Self-registration is prevented twice over. The adapter's Amp plugin already refuses to do anything
* without machine and terminal context, and both are scrubbed here; `AMP_DISABLE_PLUGINS` then stops it
* loading at all, which also keeps a user's own plugins out of a recap. `-x` archives the thread it
* creates once it finishes, so recaps do not accumulate in the user's thread list.
*/
/**
* One attempt at `amp -x`. Resolves with the text, or null when Amp gave up on its own.
*
* The prompt goes on STDIN, not argv. Measured, and the difference is not subtle: with a real ~1KB
* recap prompt as an argument, 1 run in 10 produced output — the other nine died at a flat ~32s on
* Amp's own `Error: Network timeout. Check your connection or proxy settings and retry.` The same
* prompt through stdin succeeded 2 in 3. (A trivial "reply OK" prompt succeeds either way, which is
* exactly why the first round of testing missed this.)
*/
function ampOneShotAttempt(opts: OneShotOptions, timeoutMs: number): Promise<OneShotResult | null> {
const args = ['-x', '--no-notifications', '-m', opts.model || env.AMP_SUMMARY_MODE]
const processEnv: NodeJS.ProcessEnv = scrubTerminalContext({ ...oneShotParentEnv(), AMP_DISABLE_PLUGINS: '1' })
delete processEnv.MACHINE_ID
return new Promise<OneShotResult | null>((resolve, reject) => {
const child = spawn(env.AMP_PATH || 'amp', args, {
cwd: opts.cwd, env: processEnv, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
fn()
}
const kill = (): void => {
if (child.pid == null || child.exitCode != null) return
try { process.kill(-child.pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } }
}
const onAbort = (): void => {
finish(() => reject(Object.assign(new Error('amp one-shot aborted'), { name: 'AbortError' })))
kill()
}
const timer = setTimeout(() => { finish(() => resolve(null)); kill() }, timeoutMs)
opts.signal?.addEventListener('abort', onAbort)
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', (err) => finish(() => reject(err)))
child.on('close', () => {
const text = stdout.trim()
// Amp exits non-zero even on runs that DID answer (seen repeatedly), so the output is the verdict,
// not the exit code. Empty output means this attempt failed, whatever the code says.
if (!text && stderr) console.warn(`[amp] one-shot attempt failed: ${stderr.replace(/\s+/g, ' ').slice(0, 120)}`)
finish(() => resolve(text ? { text, sessionId: null } : null))
})
try { child.stdin?.end(opts.prompt) } catch { /* the close handler reports it */ }
})
}
/**
* One-shot recap through `amp -x`, retried.
*
* Amp is the only engine here that needs retrying, and it asks for it in its own error text. Its client
* gives up on the network at a flat ~30s and exits with nothing; the very next run of the identical
* prompt often succeeds. Three attempts turn the common transient failure into an answer, and a genuine
* outage still ends in the same "no recap" it would have reached anyway.
*/
export async function runAmpOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) throw Object.assign(new Error('amp one-shot aborted'), { name: 'AbortError' })
const perAttemptMs = opts.timeoutMs ?? 90_000
for (let attempt = 1; attempt <= 3; attempt++) {
const result = await ampOneShotAttempt(opts, perAttemptMs)
if (result) return result
if (opts.signal?.aborted) throw Object.assign(new Error('amp one-shot aborted'), { name: 'AbortError' })
if (attempt < 3) console.warn(`[amp] one-shot produced nothing — retrying (${attempt + 1}/3)`)
}
throw new Error('amp one-shot produced no output after 3 attempts')
}
export function runHermesOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(Object.assign(new Error('hermes one-shot aborted'), { name: 'AbortError' }))
const args = ['chat', '-q', opts.prompt, '-Q', '--source', 'tool', ...(opts.model ? ['-m', opts.model] : [])]
const processEnv = scrubTerminalContext({ ...oneShotParentEnv() })
const timeoutMs = opts.timeoutMs ?? 60_000
return new Promise<OneShotResult>((resolve, reject) => {
const child = spawn(hermesBin(), args, {
cwd: opts.cwd, env: processEnv, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
fn()
}
const kill = (): void => {
if (child.pid == null || child.exitCode != null) return
try { process.kill(-child.pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } }
}
const onAbort = (): void => {
finish(() => reject(Object.assign(new Error('hermes one-shot aborted'), { name: 'AbortError' })))
kill()
}
const timer = setTimeout(() => {
finish(() => reject(new Error(`hermes one-shot timed out after ${timeoutMs}ms`)))
kill()
}, timeoutMs)
opts.signal?.addEventListener('abort', onAbort)
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', (err) => finish(() => reject(err)))
child.on('close', (code) => {
// `-Q` still prints a `session_id: …` line before the answer — drop it.
const text = stdout.split('\n').filter((line) => !/^\s*session_id:\s/.test(line)).join('\n').trim()
finish(() => {
if (!text && code !== 0) reject(new Error(`hermes one-shot exited ${code}: ${stderr.slice(0, 500)}`))
else resolve({ text, sessionId: null })
})
})
})
}
function devinBin(): string {
return env.DEVIN_PATH || 'devin'
}
/**
* Devin recap. Like Hermes — and unlike every pooled engine — the prompt must be **argv**: piping it to
* `devin -p` panics the CLI outright (`called Result::unwrap() on an Err value`, verified on 3000.2.17),
* so a worker cannot be pre-warmed and Devin is deliberately absent from `OneShotEngine`/the warm pool.
*
* `devin -p "<prompt>"` prints just the answer and exits 0. There is no `--no-session` equivalent, so the
* recap does leave a row in `sessions.db`; it cannot register as an agent because every terminal
* location variable is scrubbed.
*/
export function runDevinOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(Object.assign(new Error('devin one-shot aborted'), { name: 'AbortError' }))
if (opts.cwd) trustDevinWorkspace(opts.cwd, (m) => console.warn('[recap] could not trust the devin scratch dir:', m))
const args = ['-p', opts.prompt, ...(opts.model ? ['--model', opts.model] : [])]
const processEnv = scrubTerminalContext({ ...oneShotParentEnv() })
const timeoutMs = opts.timeoutMs ?? 60_000
return new Promise<OneShotResult>((resolve, reject) => {
const child = spawn(devinBin(), args, {
cwd: opts.cwd, env: processEnv, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
fn()
}
const kill = (): void => {
if (child.pid == null || child.exitCode != null) return
try { process.kill(-child.pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } }
}
const onAbort = (): void => {
finish(() => reject(Object.assign(new Error('devin one-shot aborted'), { name: 'AbortError' })))
kill()
}
const timer = setTimeout(() => {
finish(() => reject(new Error(`devin one-shot timed out after ${timeoutMs}ms`)))
kill()
}, timeoutMs)
opts.signal?.addEventListener('abort', onAbort)
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', (err) => finish(() => reject(err)))
child.on('close', (code) => {
const text = stdout.trim()
finish(() => {
if (!text && code !== 0) reject(new Error(`devin one-shot exited ${code}: ${stderr.slice(0, 500)}`))
else resolve({ text, sessionId: null })
})
})
})
}
export function runCommandCodeOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(abortError('commandcode'))
if (!config || config.cwd !== opts.cwd || config.commandcodeModel !== opts.model) {
return runDirect('commandcode', opts)
}
return pool.run('commandcode', opts)
}
export function runPiOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(abortError('pi'))
if (!config || config.cwd !== opts.cwd || config.piModel !== opts.model) {
return runDirect('pi', opts)
}
return pool.run('pi', opts)
}
export function runOpencodeOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(abortError('opencode'))
if (!config || config.cwd !== opts.cwd || config.opencodeModel !== opts.model) {
return runDirect('opencode', opts)
}
return pool.run('opencode', opts)
}
export function runKiloOneShot(opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(abortError('kilo'))
if (!config || config.cwd !== opts.cwd || config.kiloModel !== opts.model) {
return runDirect('kilo', opts)
}
return pool.run('kilo', opts)
}
// ── Voice-router warm worker ─────────────────────────────────────────────────────────────────────────
// The Overview voice router runs a tiny classifier on EVERY voice turn. Spawning the CLI each time paid a
// cold start (Node boot + CLI init + auth) that occasionally blew the 12s budget → "voice_route timed out".
// Keep ONE router worker warm (device-gated, like the recap pool) so the classify skips the spawn. Separate
// from the recap `pool` because the router uses its own small model, and only ever needs one worker.
//
// The ENGINE is chosen by the caller from the machine's live agents (see voiceRouter.chooseRouterEngine).
// It used to be pinned to claude here, which meant a machine with no Claude CLI never routed at all: the
// warm spawn failed on a loop and every voice fell through to the name-matching heuristic, whose capped
// confidence can never clear the backend's auto-dispatch threshold.
let routerConfig: { engine: OneShotEngine; cwd: string; model?: string; effort?: OneShotOptions['effort'] } | null = null
const routerPool = new DisposableOneShotPool<OneShotOptions, OneShotResult>(
async () => {
if (!routerConfig) throw new Error('router pool is not configured')
return createOneShotWorker(routerConfig.engine, routerConfig.cwd, routerConfig.model, routerConfig.effort)
},
5 * 60_000,
(line) => console.log(line.replace('[recap-pool]', '[router-pool]')),
)
/** Routing needs exactly ONE warm worker, of the configured engine — active=1 there, 0 everywhere else. */
function pinRouterEngine(engine: OneShotEngine): void {
routerPool.setActiveCounts({
claude: 0, codex: 0, cursor: 0, opencode: 0, pi: 0, commandcode: 0, [engine]: 1,
} as Parameters<typeof routerPool.setActiveCounts>[0])
}
export function configureRouterOneShot(next: { engine: OneShotEngine; cwd: string; model?: string; effort?: OneShotOptions['effort'] }): void {
const changed = routerConfig != null && (
routerConfig.engine !== next.engine || routerConfig.cwd !== next.cwd ||
routerConfig.model !== next.model || routerConfig.effort !== next.effort
)
routerConfig = next
pinRouterEngine(next.engine)
// An engine change must drop the warm worker too — it is the wrong CLI now, not just the wrong model.
if (changed) routerPool.recycleReady()
}
export function setRouterOneShotDeviceConnected(connected: boolean): void {
routerPool.setDeviceConnected(connected)
}
export function shutdownRouterOneShot(): void {
routerPool.shutdown()
}
/** Like runClaudeOneShot but served from the dedicated warm router worker (a cold spawn only if the pool
* isn't configured for this exact cwd/model/effort). */
export function runRouterOneShot(engine: OneShotEngine, opts: OneShotOptions): Promise<OneShotResult> {
if (opts.signal?.aborted) return Promise.reject(abortError(engine))
if (!routerConfig || routerConfig.engine !== engine || routerConfig.cwd !== opts.cwd ||
routerConfig.model !== opts.model || routerConfig.effort !== opts.effort) {
return runDirect(engine, opts)
}
return routerPool.run(engine, opts)
}