@@ -6,25 +6,75 @@ const execFileAsync = promisify(execFile);
66export interface HeadlessClaudeOptions {
77 cwd ?: string ;
88 timeoutMs ?: number ;
9+ // Defaults to a safe read-only set — this task family only needs to read repo files, not run
10+ // arbitrary commands or write anything. Without an explicit allow-list, headless calls have no
11+ // way to approve tool use, so anything beyond the default-allowed tools gets silently denied
12+ // and Claude wastes turns retrying workarounds instead of just reading the file.
13+ allowedTools ?: string [ ] ;
914}
1015
16+ const DEFAULT_ALLOWED_TOOLS = [ "Read" , "Grep" , "Glob" ] ;
17+
1118export interface HeadlessClaudeResult {
1219 text : string ;
13- // The canonical model ID that actually ran (e.g. "claude-sonnet-5"), read back from
14- // `--output-format json`'s modelUsage — undefined if the CLI didn't report one. Recording this
15- // per generated artifact is what Phase 4's model-routing learning reads later.
20+ // The canonical model ID that actually did the substantive work (highest-cost entry in
21+ // modelUsage — a session can involve more than one model, e.g. a cheap model for a small
22+ // sub-step alongside the model that did the real generation). Recording this per generated
23+ // artifact is what Phase 4's model-routing learning reads later.
1624 model : string | undefined ;
1725 costUsd : number | undefined ;
1826}
1927
2028export type HeadlessClaudeRunner =
2129 ( prompt : string , options ?: HeadlessClaudeOptions ) => Promise < HeadlessClaudeResult > ;
2230
31+ /** Thrown when the CLI itself reports an error (as opposed to a malformed-response parse error).
32+ * `isRateLimited` distinguishes a usage/session-limit hit (429) — expected under subscription
33+ * billing, and the caller should stop the run rather than keep retrying every remaining
34+ * candidate against the same wall — from a genuine unexpected failure. */
35+ export class HeadlessClaudeError extends Error {
36+ public readonly isRateLimited : boolean ;
37+
38+ constructor (
39+ message : string ,
40+ public readonly apiErrorStatus : number | undefined ,
41+ ) {
42+ super ( message ) ;
43+ this . name = "HeadlessClaudeError" ;
44+ this . isRateLimited = apiErrorStatus === 429 ;
45+ }
46+ }
47+
2348interface ClaudeCliJsonOutput {
2449 result : string ;
2550 is_error : boolean ; // eslint-disable-line camelcase
2651 total_cost_usd ?: number ; // eslint-disable-line camelcase
27- modelUsage ?: Record < string , unknown > ;
52+ api_error_status ?: number ; // eslint-disable-line camelcase
53+ modelUsage ?: Record < string , { costUSD ?: number } > ;
54+ }
55+
56+ export function primaryModel ( modelUsage : ClaudeCliJsonOutput [ "modelUsage" ] ) : string | undefined {
57+ if ( ! modelUsage ) return undefined ;
58+ const entries = Object . entries ( modelUsage ) ;
59+ if ( entries . length === 0 ) return undefined ;
60+ return entries . reduce ( ( a , b ) => ( ( b [ 1 ] . costUSD ?? 0 ) > ( a [ 1 ] . costUSD ?? 0 ) ? b : a ) ) [ 0 ] ;
61+ }
62+
63+ /**
64+ * The CLI often still writes valid JSON to stdout even when the process exits non-zero (e.g. a
65+ * rate limit) — execFile treats that as a rejected promise carrying an error whose `.stdout`
66+ * holds that JSON. Recover the structured error from it rather than surfacing a raw exec
67+ * failure with no usable information.
68+ */
69+ export function asCliError ( error : unknown ) : HeadlessClaudeError | undefined {
70+ const stdout = ( error as { stdout ?: string } | undefined ) ?. stdout ;
71+ if ( ! stdout ) return undefined ;
72+ try {
73+ const parsed = JSON . parse ( stdout ) as ClaudeCliJsonOutput ;
74+ return new HeadlessClaudeError ( parsed . result , parsed . api_error_status ) ;
75+ } catch {
76+ return undefined ;
77+ }
2878}
2979
3080/**
@@ -37,22 +87,31 @@ interface ClaudeCliJsonOutput {
3787 * tests instead of depending on this one directly.
3888 */
3989export const runHeadlessClaude : HeadlessClaudeRunner = async ( prompt , options = { } ) => {
40- const { stdout} = await execFileAsync (
41- "claude" ,
42- [ "-p" , prompt , "--output-format" , "json" ] ,
43- {
44- cwd : options . cwd ,
45- timeout : options . timeoutMs ,
46- maxBuffer : 1024 * 1024 * 32 ,
47- } ,
48- ) ;
90+ let stdout : string ;
91+ try {
92+ ( { stdout} = await execFileAsync (
93+ "claude" ,
94+ [
95+ "-p" , prompt ,
96+ "--output-format" , "json" ,
97+ "--allowedTools" , ( options . allowedTools ?? DEFAULT_ALLOWED_TOOLS ) . join ( "," ) ,
98+ ] ,
99+ {
100+ cwd : options . cwd ,
101+ timeout : options . timeoutMs ,
102+ maxBuffer : 1024 * 1024 * 32 ,
103+ } ,
104+ ) ) ;
105+ } catch ( e ) {
106+ throw asCliError ( e ) ?? e ;
107+ }
49108 const parsed = JSON . parse ( stdout ) as ClaudeCliJsonOutput ;
50109 if ( parsed . is_error ) {
51- throw new Error ( `Headless Claude call failed: ${ parsed . result } ` ) ;
110+ throw new HeadlessClaudeError ( parsed . result , parsed . api_error_status ) ;
52111 }
53112 return {
54113 text : parsed . result ,
55- model : parsed . modelUsage ? Object . keys ( parsed . modelUsage ) [ 0 ] : undefined ,
114+ model : primaryModel ( parsed . modelUsage ) ,
56115 costUsd : parsed . total_cost_usd ,
57116 } ;
58117} ;
0 commit comments