Skip to content

Commit c4917ff

Browse files
alari76claudeClaude (Webhook)
authored
feat(server): background AI utilities without a hardcoded vendor (#597)
* feat(server): background AI utilities without a hardcoded vendor Audit N3, the final open item: session naming and handoff distillation spawned the Claude CLI unconditionally — a hidden Claude dependency for Codex/OpenCode-only users, including inside the cross-harness handoff feature itself. Each harness now resolves a one-shot command in the registry (claude -p / codex exec / opencode run --pure), and utility-agent.ts runs the prompt through the first usable harness: the session's own provider first — the call bills the quota the user chose — then any other, with cached probes gating the chain. Failure now means no agent on the host could answer, not Claude was missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): seed utility-agent probes in session-manager tests CI run #1474 failed four scheduleSessionNaming/retrySessionNaming tests on both Node 20 and 22 — mockSpawn was never called, so sessions kept their hub: placeholder names. The tests mock child_process.spawn, but naming now goes through utility-agent, which only spawns a harness whose probe reports available+authenticated. Probes shell out with execFileSync (not the mocked spawn), so on a runner with no claude / codex / opencode installed the candidate list was empty and runUtilityPrompt threw before reaching the mock. The suite passed locally only because the dev host has the CLIs. session-naming.test.ts already seeds the probe cache; session-manager's copy of the same path did not. Seed it there too — claude usable, the other two not — and reset the cache after each test so the suite is host-independent. Verified by running the naming tests with the CLIs off PATH: 3 failures before, all green after; full suite 149 files / 2980 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Claude (Webhook) <claude-webhook@codekin.local>
1 parent 625a590 commit c4917ff

7 files changed

Lines changed: 307 additions & 101 deletions

server/handoff-manager.ts

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,11 @@
1313
* See docs/SESSION-HANDOFF-SPEC.md.
1414
*/
1515

16-
import { spawn } from 'node:child_process'
1716
import { mkdirSync, writeFileSync } from 'fs'
18-
import { tmpdir } from 'node:os'
1917
import { join } from 'path'
2018
import type { CodingProvider } from './coding-process.js'
21-
import { CLAUDE_BINARY, DATA_DIR } from './config.js'
22-
import { buildOneShotCliEnv } from './session-naming.js'
19+
import { DATA_DIR } from './config.js'
20+
import { runUtilityPrompt } from './utility-agent.js'
2321
import { findTranscript, readCondensed } from './transcript-readers.js'
2422

2523
/** Chars of condensed transcript fed to the distiller (≈20k tokens). */
@@ -76,38 +74,9 @@ export interface HandoffSource {
7674
export type DistillFn = (systemPrompt: string, prompt: string) => Promise<string>
7775

7876
function distillViaCli(systemPrompt: string, prompt: string): Promise<string> {
79-
return new Promise((resolve, reject) => {
80-
// tmpdir cwd: prevent project CLAUDE.md/hooks from loading into the
81-
// distillation turn. Tools disabled — the transcript extract is the input.
82-
const proc = spawn(CLAUDE_BINARY, ['-p', '--max-turns', '1', '--tools', '', '--system-prompt', systemPrompt], {
83-
stdio: ['pipe', 'pipe', 'pipe'],
84-
cwd: tmpdir(),
85-
env: buildOneShotCliEnv(),
86-
})
87-
88-
let stdout = ''
89-
let stderr = ''
90-
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
91-
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
92-
93-
const timer = setTimeout(() => {
94-
proc.kill('SIGTERM')
95-
reject(new Error('handoff distillation timed out'))
96-
}, DISTILL_TIMEOUT_MS)
97-
98-
proc.on('close', (code) => {
99-
clearTimeout(timer)
100-
if (code === 0 && stdout.trim()) resolve(stdout.trim())
101-
else reject(new Error(`claude -p exited with code ${code}: ${stderr.trim().slice(0, 500)}`))
102-
})
103-
proc.on('error', (err) => {
104-
clearTimeout(timer)
105-
reject(err)
106-
})
107-
108-
proc.stdin.write(prompt)
109-
proc.stdin.end()
110-
})
77+
// Through the utility agent (audit N3): any usable harness distills — a
78+
// Codex-only host no longer silently needs a Claude install for handoffs.
79+
return runUtilityPrompt({ prompt, systemPrompt, timeoutMs: DISTILL_TIMEOUT_MS }).then((r) => r.text)
11180
}
11281

11382
/**

server/harness-registry.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ export interface CreateProcessContext {
4242
mergedAllowedTools: string[]
4343
}
4444

45+
export interface OneShotOptions {
46+
prompt: string
47+
systemPrompt?: string
48+
/** Prefer a small/cheap model where the harness lets us pick one. */
49+
fast?: boolean
50+
}
51+
52+
/** A fully-resolved one-shot invocation: binary + args + what to pipe to stdin. */
53+
export interface OneShotCommand {
54+
binary: string
55+
args: string[]
56+
stdin: string
57+
}
58+
4559
export interface HarnessDefinition {
4660
id: CodingProvider
4761
label: string
@@ -51,6 +65,11 @@ export interface HarnessDefinition {
5165
probe(): HarnessProbe
5266
/** Build (but do not start) the session's process. */
5367
createProcess(session: Session, ctx: CreateProcessContext): CodingProcess
68+
/**
69+
* Resolve a non-interactive single-prompt invocation (session naming,
70+
* handoff distillation — see utility-agent.ts). Pure: callers execute it.
71+
*/
72+
oneShotCommand(opts: OneShotOptions): OneShotCommand
5473
}
5574

5675
function tryVersion(binary: string): string | null {
@@ -80,6 +99,12 @@ const claude: HarnessDefinition = {
8099
}
81100
return { available: true, version, authenticated }
82101
},
102+
oneShotCommand(opts) {
103+
const args = ['-p', '--max-turns', '1', '--tools', '']
104+
if (opts.systemPrompt) args.push('--system-prompt', opts.systemPrompt)
105+
if (opts.fast) args.push('--model', 'haiku')
106+
return { binary: CLAUDE_BINARY, args, stdin: opts.prompt }
107+
},
83108
createProcess(session, ctx) {
84109
return new ClaudeProcess(session.workingDir, {
85110
sessionId: session.claudeSessionId || undefined,
@@ -106,6 +131,12 @@ const opencode: HarnessDefinition = {
106131
? { available: false, version: '', authenticated: false }
107132
: { available: true, version, authenticated: true }
108133
},
134+
oneShotCommand(opts) {
135+
// `opencode run --pure` prints the reply and loads no plugins. It has no
136+
// separate system-prompt channel, so the system prompt leads the message.
137+
const text = opts.systemPrompt ? `${opts.systemPrompt}\n\n${opts.prompt}` : opts.prompt
138+
return { binary: process.env.OPENCODE_BINARY || 'opencode', args: ['run', '--pure', text], stdin: '' }
139+
},
109140
createProcess(session, ctx) {
110141
// Recent assistant text already shown to the user — lets the resumed
111142
// process skip re-emitting messages during missed-history hydration.
@@ -136,6 +167,12 @@ const codex: HarnessDefinition = {
136167
const authenticated = existsSync(join(codexHome, 'auth.json')) || !!process.env.OPENAI_API_KEY
137168
return { available: true, version, authenticated }
138169
},
170+
oneShotCommand(opts) {
171+
// `codex exec` reads instructions from stdin when no prompt argument is
172+
// given. No system-prompt channel either — it leads the piped text.
173+
const text = opts.systemPrompt ? `${opts.systemPrompt}\n\n${opts.prompt}` : opts.prompt
174+
return { binary: process.env.CODEX_BINARY || 'codex', args: ['exec'], stdin: text }
175+
},
139176
createProcess(session, ctx) {
140177
return new CodexProcess(session.workingDir, {
141178
sessionId: ctx.sessionId,

server/session-manager.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ vi.mock('child_process', async (importOriginal) => {
6767
})
6868

6969
import { SessionManager } from './session-manager.js'
70+
import { seedUtilityProbe, resetUtilityProbeCache } from './utility-agent.js'
7071
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from 'fs'
7172
import { EventEmitter } from 'node:events'
7273

@@ -150,6 +151,19 @@ describe('SessionManager', () => {
150151

151152
beforeEach(() => {
152153
sm = new SessionManager()
154+
// Session naming runs through the utility agent, which only spawns a
155+
// harness whose probe reports available+authenticated. Real probes shell
156+
// out with execFileSync (not the mocked spawn), so on a host without the
157+
// CLIs installed — CI — nothing would be spawned at all. Seed the cache:
158+
// claude is the one usable one-shot harness under test.
159+
resetUtilityProbeCache()
160+
seedUtilityProbe('claude', { available: true, version: 'test', authenticated: true })
161+
seedUtilityProbe('codex', { available: false, version: '', authenticated: false })
162+
seedUtilityProbe('opencode', { available: false, version: '', authenticated: false })
163+
})
164+
165+
afterEach(() => {
166+
resetUtilityProbeCache()
153167
})
154168

155169
describe('CRUD', () => {

server/session-naming.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/* eslint-disable @typescript-eslint/no-explicit-any */
33
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
44
import { SessionNaming, type SessionNamingDeps } from './session-naming.js'
5+
import { seedUtilityProbe, resetUtilityProbeCache } from './utility-agent.js'
56
import { EventEmitter } from 'node:events'
67

78
// Mock child_process.spawn
@@ -60,6 +61,12 @@ function fakeSession(overrides: Record<string, any> = {}): any {
6061
describe('SessionNaming', () => {
6162
beforeEach(() => {
6263
mockSpawn.mockReset()
64+
// Probes shell out via the mocked child_process — seed the utility-agent
65+
// cache so claude is the (only) usable one-shot harness under test.
66+
resetUtilityProbeCache()
67+
seedUtilityProbe('claude', { available: true, version: 'test', authenticated: true })
68+
seedUtilityProbe('codex', { available: false, version: '', authenticated: false })
69+
seedUtilityProbe('opencode', { available: false, version: '', authenticated: false })
6370
})
6471

6572
afterEach(() => {
@@ -181,7 +188,7 @@ describe('SessionNaming', () => {
181188
expect(deps.rename).toHaveBeenCalledWith('s1', 'Fix Login Page Styling')
182189
expect(mockSpawn).toHaveBeenCalledWith(
183190
'claude',
184-
['-p', '--max-turns', '1', '--model', 'haiku', '--tools', '', '--system-prompt', expect.any(String)],
191+
['-p', '--max-turns', '1', '--tools', '', '--system-prompt', expect.any(String), '--model', 'haiku'],
185192
expect.objectContaining({
186193
stdio: ['pipe', 'pipe', 'pipe'],
187194
cwd: expect.any(String),

server/session-naming.ts

Lines changed: 17 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
* with exponential back-off.
88
*/
99

10-
import { spawn } from 'node:child_process'
11-
import { tmpdir } from 'node:os'
1210
import type { Session, WsServerMessage } from './types.js'
13-
import { CLAUDE_BINARY } from './config.js'
11+
import type { CodingProvider } from './coding-process.js'
12+
import { runUtilityPrompt } from './utility-agent.js'
1413

1514
/** Max naming retry attempts before giving up. */
1615
const MAX_NAMING_ATTEMPTS = 5
@@ -43,66 +42,20 @@ export interface SessionNamingDeps {
4342
isRateLimited(): boolean
4443
}
4544

46-
/** Build a minimal env for claude -p that includes auth/config paths
47-
* without leaking the full parent env (e.g. CODEKIN_* session vars).
48-
* Shared with handoff distillation (handoff-manager.ts). */
49-
export function buildOneShotCliEnv(): Record<string, string> {
50-
const env: Record<string, string> = {}
51-
// Core paths
52-
if (process.env.PATH) env.PATH = process.env.PATH
53-
if (process.env.HOME) env.HOME = process.env.HOME
54-
// XDG dirs — Claude CLI uses these for config/credential resolution
55-
for (const key of ['XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME']) {
56-
if (process.env[key]) env[key] = process.env[key]!
57-
}
58-
// SHELL and TERM for proper subprocess behavior
59-
if (process.env.SHELL) env.SHELL = process.env.SHELL
60-
if (process.env.TERM) env.TERM = process.env.TERM
61-
// Suppress Node.js warnings in child
62-
env.NODE_NO_WARNINGS = '1'
63-
return env
64-
}
65-
66-
/** Generate a session name by spawning `claude -p` in one-shot mode. */
67-
function generateNameViaCLI(prompt: string): Promise<string> {
68-
return new Promise((resolve, reject) => {
69-
// Run in tmpdir so no project CLAUDE.md/hooks/skills get auto-loaded —
70-
// those would inject unrelated context and the model ends up responding
71-
// to that instead of the naming prompt. --tools "" disables tools so the
72-
// model can't burn its single turn on a tool call.
73-
const proc = spawn(CLAUDE_BINARY, ['-p', '--max-turns', '1', '--model', 'haiku', '--tools', '', '--system-prompt', NAMING_SYSTEM_PROMPT], {
74-
stdio: ['pipe', 'pipe', 'pipe'],
75-
cwd: tmpdir(),
76-
env: buildOneShotCliEnv(),
77-
})
78-
79-
let stdout = ''
80-
let stderr = ''
81-
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
82-
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
83-
84-
const timer = setTimeout(() => {
85-
proc.kill('SIGTERM')
86-
reject(new Error('claude -p timed out'))
87-
}, CLI_TIMEOUT_MS)
88-
89-
proc.on('close', (code) => {
90-
clearTimeout(timer)
91-
if (code === 0 && stdout.trim()) {
92-
resolve(stdout.trim())
93-
} else {
94-
reject(new Error(`claude -p exited with code ${code}: ${stderr.trim()}`))
95-
}
96-
})
97-
98-
proc.on('error', (err) => {
99-
clearTimeout(timer)
100-
reject(err)
101-
})
102-
103-
proc.stdin.write(prompt)
104-
proc.stdin.end()
105-
})
45+
/**
46+
* Generate a session name via a one-shot utility prompt. Runs through the
47+
* harness registry (utility-agent.ts): the session's own harness first —
48+
* the naming call bills the quota the user chose — then any other usable
49+
* agent. No hardcoded vendor (audit N3).
50+
*/
51+
function generateNameViaCLI(prompt: string, prefer?: CodingProvider): Promise<string> {
52+
return runUtilityPrompt({
53+
prompt,
54+
systemPrompt: NAMING_SYSTEM_PROMPT,
55+
fast: true,
56+
prefer,
57+
timeoutMs: CLI_TIMEOUT_MS,
58+
}).then((r) => r.text)
10659
}
10760

10861
export class SessionNaming {
@@ -178,7 +131,7 @@ export class SessionNaming {
178131
`Assistant response (truncated): ${latestContext.slice(0, 1500)}`,
179132
].join('\n')
180133

181-
const text = await generateNameViaCLI(prompt)
134+
const text = await generateNameViaCLI(prompt, session.provider)
182135

183136
if (!this.deps.hasSession(sessionId)) return
184137
if (!session.name.startsWith('hub:')) return

server/utility-agent.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/** Tests for the utility agent — provider ordering, fallback chain, and per-harness one-shot command mapping. */
2+
import { describe, it, expect, beforeEach } from 'vitest'
3+
import { runUtilityPrompt, utilityOrder, seedUtilityProbe, resetUtilityProbeCache } from './utility-agent.js'
4+
import { getHarness } from './harness-registry.js'
5+
6+
function seedAll(overrides: Partial<Record<'claude' | 'opencode' | 'codex', boolean>> = {}) {
7+
for (const id of ['claude', 'opencode', 'codex'] as const) {
8+
const usable = overrides[id] ?? true
9+
seedUtilityProbe(id, { available: usable, version: usable ? 'v' : '', authenticated: usable })
10+
}
11+
}
12+
13+
beforeEach(() => {
14+
resetUtilityProbeCache()
15+
})
16+
17+
describe('utilityOrder', () => {
18+
it('prefers the requested provider, then registry order', () => {
19+
seedAll()
20+
expect(utilityOrder('codex').map((h) => h.id)).toEqual(['codex', 'claude', 'opencode'])
21+
expect(utilityOrder().map((h) => h.id)).toEqual(['claude', 'opencode', 'codex'])
22+
})
23+
24+
it('drops harnesses that are missing or unauthenticated', () => {
25+
seedAll({ claude: false })
26+
expect(utilityOrder('claude').map((h) => h.id)).toEqual(['opencode', 'codex'])
27+
})
28+
})
29+
30+
describe('runUtilityPrompt', () => {
31+
it('uses the preferred harness when it succeeds', async () => {
32+
seedAll()
33+
const result = await runUtilityPrompt(
34+
{ prompt: 'name this', prefer: 'opencode', timeoutMs: 1000 },
35+
async (cmd) => `${cmd.binary} said hi`,
36+
)
37+
expect(result.provider).toBe('opencode')
38+
})
39+
40+
it('falls back down the chain and reports every failure when all fail', async () => {
41+
seedAll()
42+
const tried: string[] = []
43+
await expect(
44+
runUtilityPrompt({ prompt: 'p', timeoutMs: 1000 }, async (cmd) => {
45+
tried.push(cmd.binary)
46+
throw new Error('nope')
47+
}),
48+
).rejects.toThrow(/claude.*opencode.*codex/s)
49+
expect(tried).toHaveLength(3)
50+
})
51+
52+
it('a mid-chain success stops the fallback', async () => {
53+
seedAll({ claude: false })
54+
const result = await runUtilityPrompt({ prompt: 'p', timeoutMs: 1000 }, async (cmd) =>
55+
cmd.args[0] === 'run' ? 'from opencode' : Promise.reject(new Error('x')),
56+
)
57+
expect(result).toEqual({ text: 'from opencode', provider: 'opencode' })
58+
})
59+
60+
it('throws immediately when no harness is usable', async () => {
61+
seedAll({ claude: false, opencode: false, codex: false })
62+
await expect(runUtilityPrompt({ prompt: 'p', timeoutMs: 1000 })).rejects.toThrow(/No usable coding agent/)
63+
})
64+
})
65+
66+
describe('oneShotCommand mapping', () => {
67+
const opts = { prompt: 'the prompt', systemPrompt: 'be terse', fast: true }
68+
69+
it('claude: -p one-shot with system prompt, fast model, prompt on stdin', () => {
70+
const cmd = getHarness('claude').oneShotCommand(opts)
71+
expect(cmd.args).toEqual(['-p', '--max-turns', '1', '--tools', '', '--system-prompt', 'be terse', '--model', 'haiku'])
72+
expect(cmd.stdin).toBe('the prompt')
73+
})
74+
75+
it('codex: exec with the system prompt leading the piped text', () => {
76+
const cmd = getHarness('codex').oneShotCommand(opts)
77+
expect(cmd.args).toEqual(['exec'])
78+
expect(cmd.stdin).toBe('be terse\n\nthe prompt')
79+
})
80+
81+
it('opencode: run --pure with the combined message as the argument', () => {
82+
const cmd = getHarness('opencode').oneShotCommand(opts)
83+
expect(cmd.args).toEqual(['run', '--pure', 'be terse\n\nthe prompt'])
84+
expect(cmd.stdin).toBe('')
85+
})
86+
})

0 commit comments

Comments
 (0)