Routes each Claude Code turn to Haiku, Opus or Fable using TypeSafe's Jev, asks you when Jev is unsure, sends all image work to OpenAI GPT-Image-2.5, and…

A Claude Code mod that routes every turn to the right model.
/route overrides, locks, and explains.turn.step hook sends every model request of the turn to the chosen model, and subagents that don't name a model follow the same route.generate_image and edit_image. By default they run through the Codex CLI on your ChatGPT plan, so no API key is needed; set imageBackend to api to use the Images API instead. Image turns run on Haiku, which just drives those tools. Flare is the default tier; Sunburst is used when Jev flags a premium asset or Claude asks for it.| Route | Model (default id) | Used for |
|---|---|---|
| haiku | claude-haiku-5-5 | Lookups, small edits, summaries, commit messages, chat |
| opus | claude-opus-5-5 | Ordinary engineering, and the fallback for everything |
| fable | claude-fable-5-1 | Architecture, long agentic work, deep ambiguous problems |
| image | Haiku + Codex image generation (or gpt-image-2.5-flare / -sunburst on the API) | Creating or editing any image |
| security | Opus + OpenAI Daybreak Blue through Codex (--cyber-access-program daybreak_blue), read-only | Vulnerability review, threat modeling, secure code review, after you say yes |
Rules on top of Jev:
security, or Jev's security check is over 50%) asks first: "Send it to OpenAI Daybreak?" Yes runs the turn on Opus, which hands the analysis to the daybreak tool. That tool runs Codex read-only on your project and returns the findings, and Claude applies fixes only if you ask. No, or nobody answering, means Opus. Security work never goes to Fable, which carries extra safeguards for cybersecurity.daybreak tool refuses on any turn you didn't approve (a yes, or /route security), so your code never goes to OpenAI unasked.confidenceThreshold (default 0.7): you are asked.confirmFable is on: you are asked.The route descriptions Jev scores against live in hooks/jev.ts (ROUTE_CRITERIA). Editing them is how you tune the policy.
TYPESAFE_API_KEY: a Jev key from console.typesafe.ai (early access). Without it the router still works on the Haiku fallback.codex login using your ChatGPT account. On Windows the mod runs node.exe from Program Files\nodejs on Codex's npm install by absolute path; if yours lives elsewhere, set codexPath.OPENAI_API_KEY with access to gpt-image-2.5-flare and gpt-image-2.5-sunburst. On macOS or Linux, curl and openssl on the PATH; on Windows nothing extra (System32 curl.exe and powershell.exe by absolute path).daybreakProgram (default daybreak_blue), but your OpenAI account decides what you actually get. Codex doesn't say whether the program was granted or the server fell back to standard access, and no setting here relaxes guardrails your account doesn't have.Put the keys in your shell profile or in ~/.claude/settings.json under env. There is no .env file: the mod reads Claude Code's own environment.
To test in one folder without touching your global settings, copy examples/settings.local.json to <test folder>/.claude/settings.local.json, fill in the keys, and start claude --plugin-dir <path to jev-router> from that folder. The keys apply to that folder only. The mod itself still needs --plugin-dir, because CLAUDE_CODE_PLUGIN_DIRS in a folder's settings does not load it.
Try it for one session:
claude --plugin-dir /path/to/jev-router
Load it in every session by adding the folder to ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/jev-router",
"TYPESAFE_API_KEY": "...",
"OPENAI_API_KEY": "..."
}
}
Check it loaded: run /plugin and look for 1 mod active · jev-router. Before installing, claude plugin validate ./jev-router lists every event it hooks and every call it makes.
| Command | Effect |
|---|---|
/route or /route why | The last decision: route, model, reason, and Jev's full probability breakdown |
/route opus (or haiku, fable, image, security) | Next turn only, then Jev decides again. /route security counts as approving Daybreak |
/route lock fable | Every turn on that route until /route auto |
/route auto | Hand control back to Jev |
/route log | The last 10 decisions |
/route runs immediately, even while Claude is working. The line under the prompt always shows the current route, who decided, and the confidence, for example Opus 5.5 (Jev 91%).
Change these in /config (each is a row there) or under pluginConfigs in settings, keyed jev-router@inline for a --plugin-dir install:
| Setting | Default | |
|---|---|---|
haikuModel, opusModel, fableModel | claude-haiku-5-5, claude-opus-5-5, claude-fable-5-1 | Model ids per route |
confidenceThreshold | 0.7 | Ask below this |
confirmFable | true | Ask before any Fable turn |
routeSubagents | true | Subagents without a model follow the route |
imageDir | assets/generated | Where images are saved |
imageBackend | codex | codex (ChatGPT plan, through the Codex CLI) or api (Images API, OPENAI_API_KEY). No automatic fallback between them |
codexPath | empty | Where Codex is, if not found automatically: codex.js on Windows, the codex executable elsewhere |
daybreakProgram | daybreak_blue | Cyber access program requested for approved security work: daybreak_blue, daybreak_red (approved partners only) or standard |
daybreakModel | empty | Empty uses Codex's default model; otherwise the model id passed with -m |
logPath | .jev-router/decisions.jsonl | Decision log (add it to .gitignore) |
Every decision, override, image call and finished turn (with its token usage) is one JSON line in logPath. Turns where you overrode Jev, or picked differently when asked, are your labeled data for tuning ROUTE_CRITERIA and the threshold. The log keeps the last 5,000 lines.
imageDir, and it counts against your ChatGPT plan's Codex limits. The mod checks that the file exists afterwards rather than trusting Codex's reply.elevated sandbox setup ("setup refresh had errors") makes every read fail, and the report then says it couldn't read the files. Check with codex exec -s read-only "print the first line of README.md" in any project. The mod leaves the sandbox mode to your Codex config.claude plugin validate ./jev-router
claude plugin test ./jev-router
The suite (36 tests) covers confident routing, low-confidence asks, Fable confirmation, the Daybreak ask and the daybreak tool's approval gate, image routing, both fallbacks, subagent routing, every /route form, settings, and both image backends, with Jev, OpenAI, Codex, files and dialogs answered in memory.
hooks/register.ts 554 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { type Classification, type Route, ROUTES, classifyWithHaiku, classifyWithJev, isRoute, ranked } from './jev'
3import { type ImageTier, editImage, generateImage } from './images'
4import { codexImage } from './codex'
5import { type DaybreakProgram, daybreakName, daybreakReview } from './daybreak'
6
7/** One routing decision: what Jev said, what was chosen, and why. */
8type Decision = {
9 at: string
10 prompt: string
11 route: Route
12 model: string
13 imageTier?: ImageTier
14 decidedBy: 'jev' | 'you' | 'override' | 'lock' | 'rule'
15 reason: string
16 classification?: Classification
17}
18
19type Override = { route: Route; sticky: boolean }
20
21type Opts = {
22 haikuModel: string
23 opusModel: string
24 fableModel: string
25 threshold: number
26 confirmFable: boolean
27 routeSubagents: boolean
28 imageDir: string
29 imageBackend: 'codex' | 'api'
30 codexPath: string
31 daybreakProgram: DaybreakProgram
32 daybreakModel: string
33 logPath: string
34}
35
36const LABEL: Record<Route, string> = {
37 haiku: 'Haiku 5.5',
38 opus: 'Opus 5.5',
39 fable: 'Fable 5.1',
40 image: 'Images 2.5',
41 security: 'Daybreak',
42}
43
44const MAX_LOG_LINES = 5000
45
46// Codex writes Daybreak's report here; the folder is the decision log's, which stays out of git.
47const DAYBREAK_REPLY = '.jev-router/daybreak-last-report.md'
48
49// Module state. A reload starts it over, which is fine: the next prompt decides again.
50const S: { opt: Opts; active?: Decision; override?: Override } = { opt: readOptions({}) }
51
52export const register: Register = (on, options) => {
53 S.opt = readOptions(options)
54 S.active = undefined
55 S.override = undefined
56
57 on('session.start', async ($, e, next) => {
58 await registerTools($)
59 try {
60 await $.command.register({
61 name: 'route',
62 description: 'Jev router: why | haiku | opus | fable | image | security | lock <route> | auto | log',
63 argumentHint: '[why|haiku|opus|fable|image|security|lock <route>|auto|log]',
64 immediate: true,
65 })
66 } catch (err) {
67 $.ui.log(`/route was not registered: ${String(err)}`)
68 }
69 return next(e)
70 })
71
72 on('command.run', { command: 'route' }, async ($, e) => ({ text: await runRouteCommand($, e.args) }))
73
74 // Decide: classify each prompt typed while the session is idle.
75 on('prompt.submit', async ($, e, next) => {
76 if (e.command || e.isMeta || e.turnId) return next(e)
77
78 const decision = await decide($, e.text)
79 S.active = decision
80 showStatus($, decision)
81 await writeLog($, { type: 'decision', ...decision })
82
83 const tool = `mcp__${$.plugin.name}__`
84 const note =
85 decision.route === 'image'
86 ? `jev-router: this request is image work. Create images with ${tool}generate_image and change existing ones with ${tool}edit_image, using tier "${decision.imageTier}". Do not draw images by hand in SVG, HTML or code. Report the saved file path when done.`
87 : decision.route === 'security'
88 ? `jev-router: the user approved sending this security work to OpenAI ${daybreakLabel()}. Call ${tool}daybreak with the full task and the most relevant file paths; it reads the project read-only and returns findings. Report those findings to the user, and change code only if they ask.`
89 : undefined
90 if (!note) return next(e)
91 return next({ ...e, context: [...(e.context ?? []), note] })
92 }).catch(($, e, next) => {
93 // Never lose a prompt to a routing failure: send it on unrouted (the session's own model).
94 $.ui.status('Jev router failed: this turn uses the session model')
95 return next.called ? { text: e.text } : next(e)
96 })
97
98 // Enforce: every model request of the main loop goes to the routed model.
99 on('turn.step', async function* ($, e, next) {
100 const d = S.active
101 // Subagent loops are routed when they spawn (agent.spawn); leave their steps alone.
102 if (!d || e.agentId || e.model === d.model) return yield* next(e)
103 return yield* next({ ...e, model: d.model })
104 })
105
106 // Subagents that name no model run on the turn's routed model.
107 on('agent.spawn', async ($, e, next) => {
108 const d = S.active
109 if (!S.opt.routeSubagents || !d || e.model) return next(e)
110 return next({ ...e, model: d.model })
111 }).catch(($, e, next) => next(e))
112
113 // Record what each main turn cost, for tuning later.
114 on('turn.complete', async ($, e, next) => {
115 const d = S.active
116 if (!e.agentId && d) {
117 await writeLog($, {
118 type: 'turn',
119 at: new Date().toISOString(),
120 turnId: e.turnId,
121 route: d.route,
122 model: e.usage?.model ?? d.model,
123 durationMs: e.durationMs,
124 isAborted: e.isAborted,
125 usage: e.usage,
126 })
127 }
128 return next(e)
129 })
130
131 on('tool.call', { tool: 'mcp__jev-router__generate_image' }, async ($, e) => ({
132 result: await runGenerate($, e as unknown as Record<string, unknown>),
133 })).catch(($, e, next) => ({ result: `generate_image failed inside jev-router: ${next.error?.message ?? 'unknown error'}` }))
134
135 on('tool.call', { tool: 'mcp__jev-router__edit_image' }, async ($, e) => ({
136 result: await runEdit($, e as unknown as Record<string, unknown>),
137 })).catch(($, e, next) => ({ result: `edit_image failed inside jev-router: ${next.error?.message ?? 'unknown error'}` }))
138
139 on('tool.call', { tool: 'mcp__jev-router__daybreak' }, async ($, e) => ({
140 result: await runDaybreak($, e as unknown as Record<string, unknown>),
141 })).catch(($, e, next) => ({ result: `daybreak failed inside jev-router: ${next.error?.message ?? 'unknown error'}` }))
142}
143
144// ---------- options ----------
145
146function readOptions(options: PluginOptions): Opts {
147 return {
148 haikuModel: String(options.haikuModel ?? 'claude-haiku-5-5'),
149 opusModel: String(options.opusModel ?? 'claude-opus-5-5'),
150 fableModel: String(options.fableModel ?? 'claude-fable-5-1'),
151 threshold: Number(options.confidenceThreshold ?? 0.7),
152 confirmFable: options.confirmFable !== false,
153 routeSubagents: options.routeSubagents !== false,
154 imageDir: String(options.imageDir ?? 'assets/generated'),
155 imageBackend: options.imageBackend === 'api' ? 'api' : 'codex',
156 codexPath: String(options.codexPath ?? ''),
157 daybreakProgram: options.daybreakProgram === 'daybreak_red' || options.daybreakProgram === 'standard' ? options.daybreakProgram : 'daybreak_blue',
158 daybreakModel: String(options.daybreakModel ?? ''),
159 logPath: String(options.logPath ?? '.jev-router/decisions.jsonl'),
160 }
161}
162
163// The image route runs on Haiku: it only has to drive the image tools. The security route runs on
164// Opus, which hands the analysis to Daybreak and then explains and applies its findings.
165function modelFor(r: Route): string {
166 return r === 'fable' ? S.opt.fableModel : r === 'opus' || r === 'security' ? S.opt.opusModel : S.opt.haikuModel
167}
168
169// ---------- deciding ----------
170
171async function decide($: EngineInterface, text: string): Promise<Decision> {
172 const base = { at: new Date().toISOString(), prompt: text.slice(0, 500) }
173
174 const o = S.override
175 if (o) {
176 if (!o.sticky) S.override = undefined
177 return {
178 ...base,
179 route: o.route,
180 model: modelFor(o.route),
181 imageTier: o.route === 'image' ? 'flare' : undefined,
182 decidedBy: o.sticky ? 'lock' : 'override',
183 reason: o.sticky ? 'locked with /route lock' : 'set with /route',
184 }
185 }
186
187 const c = await classify($, text)
188 let route = c.route
189 let decidedBy: Decision['decidedBy'] = 'jev'
190 let reason =
191 c.source === 'jev'
192 ? `Jev picked ${route} at ${pct(c.confidence)}`
193 : c.source === 'fallback-haiku'
194 ? `Jev unavailable (${c.error}); Haiku picked ${route}`
195 : `Jev unavailable (${c.error}); default route`
196
197 // Security work goes to OpenAI Daybreak, but only on a yes. Otherwise Opus, never Fable,
198 // which carries extra safeguards for cybersecurity.
199 if (route === 'security' || (route !== 'image' && c.securitySensitive > 0.5)) {
200 const why =
201 route === 'security' ? `${classifierName(c)} flagged security work` : `security-sensitive (${pct(c.securitySensitive)})`
202 const answer = await askSafely(
203 $,
204 `${why}. Send it to OpenAI ${daybreakLabel()}, read-only through Codex?`,
205 ['OpenAI Daybreak', 'Opus 5.5'],
206 )
207 route = answer === 'OpenAI Daybreak' ? 'security' : 'opus'
208 decidedBy = answer === undefined ? 'rule' : 'you'
209 reason =
210 answer === 'OpenAI Daybreak'
211 ? `${why}; you approved Daybreak`
212 : answer === undefined
213 ? `${why}; nobody approved Daybreak, so Opus`
214 : `${why}; you chose Opus over Daybreak`
215 }
216
217 if (decidedBy === 'jev' && c.source === 'jev' && c.confidence < S.opt.threshold) {
218 // Jev is unsure: ask, showing its ranking.
219 const picked = await askForRoute($, c)
220 if (picked) {
221 route = picked
222 decidedBy = 'you'
223 reason = `Jev unsure (${pct(c.confidence)}); you picked ${route}`
224 } else if (route === 'fable' && S.opt.confirmFable) {
225 // Nobody picked, and Fable needs a yes: don't spend it unasked.
226 route = 'opus'
227 decidedBy = 'rule'
228 reason = `Jev unsure (${pct(c.confidence)}) and nobody picked; Fable needs a yes, so Opus`
229 }
230 } else if (route === 'fable' && S.opt.confirmFable && decidedBy === 'jev') {
231 const answer = await askSafely(
232 $,
233 `${classifierName(c)} wants Fable 5.1 for this turn (${pct(c.confidence)}). Use it?`,
234 ['Fable 5.1', 'Opus 5.5'],
235 )
236 if (answer === 'Opus 5.5') {
237 route = 'opus'
238 decidedBy = 'you'
239 reason = 'you chose Opus over Fable'
240 } else if (answer !== 'Fable 5.1') {
241 // Dismissed, or a -p run with nobody to ask: Fable runs only on a yes.
242 route = 'opus'
243 decidedBy = 'rule'
244 reason = 'nobody confirmed Fable, so Opus'
245 }
246 }
247
248 return {
249 ...base,
250 route,
251 model: modelFor(route),
252 imageTier: route === 'image' ? (c.premiumImage > 0.5 ? 'sunburst' : 'flare') : undefined,
253 decidedBy,
254 reason,
255 classification: c,
256 }
257}
258
259async function classify($: EngineInterface, text: string): Promise<Classification> {
260 const state = await buildState($, text)
261 const key = await $.env.get('TYPESAFE_API_KEY')
262 if (!key) return classifyWithHaiku($, state, 'TYPESAFE_API_KEY is not set')
263 try {
264 return await classifyWithJev($, key, state)
265 } catch (err) {
266 return classifyWithHaiku($, state, err instanceof Error ? err.message : String(err))
267 }
268}
269
270// Jev reads the new prompt plus the tail of the last reply, so "yes, do that" routes like what it answers.
271async function buildState($: EngineInterface, text: string): Promise<string> {
272 let lastReply = ''
273 try {
274 const messages = await $.session.messages()
275 const last = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim().length > 0)
276 lastReply = last ? last.text.slice(-1500) : ''
277 } catch {
278 // no transcript to read
279 }
280 return lastReply ? `Previous assistant reply (tail):\n${lastReply}\n\nNew request:\n${text}` : `New request:\n${text}`
281}
282
283async function askForRoute($: EngineInterface, c: Classification): Promise<Route | undefined> {
284 const top = ranked(c).slice(0, 3)
285 const labels = top.map(r => `${LABEL[r]} (${pct(c.probabilities[r] ?? 0)})`)
286 const answer = await askSafely($, `Jev is unsure (${pct(c.confidence)}). Which model should take this turn?`, labels)
287 if (answer === undefined) return undefined
288 const i = labels.indexOf(answer)
289 if (i >= 0) return top[i]
290 return ROUTES.find(r => answer.toLowerCase().includes(r))
291}
292
293async function askSafely($: EngineInterface, question: string, labels: string[]): Promise<string | undefined> {
294 try {
295 return await $.ui.ask(question, labels)
296 } catch {
297 return undefined // dismissed, or a -p run with nobody to ask: keep Jev's pick
298 }
299}
300
301// ---------- /route ----------
302
303async function runRouteCommand($: EngineInterface, args: string): Promise<string> {
304 const [first, second] = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
305
306 if (!first || first === 'why') {
307 // A reload clears S.active; explain the last logged decision instead. Only for display:
308 // S.active stays empty, so a stale decision never routes a turn or approves Daybreak.
309 if (S.active) return explain(S.active, S.override)
310 return explain(await lastLoggedDecision($), S.override, true)
311 }
312
313 if (first === 'auto') {
314 S.override = undefined
315 $.ui.status(S.active ? `${LABEL[S.active.route]} (Jev, auto)` : 'Jev: auto')
316 return 'Jev is routing again.'
317 }
318
319 if (first === 'log') return recentDecisions($)
320
321 const lock = first === 'lock'
322 const target = lock ? second : first
323 if (!isRoute(target)) return 'Usage: /route [why | haiku | opus | fable | image | security | lock <route> | auto | log]'
324
325 S.override = { route: target, sticky: lock }
326 $.ui.status(`${LABEL[target]} (${lock ? 'locked' : 'next turn'})`)
327 await writeLog($, { type: 'override', at: new Date().toISOString(), route: target, sticky: lock })
328 return lock
329 ? `Locked to ${LABEL[target]} until /route auto.`
330 : `Next turn runs on ${LABEL[target]}, then Jev takes over again.`
331}
332
333async function recentDecisions($: EngineInterface): Promise<string> {
334 const path = S.opt.logPath
335 const prior = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
336 const rows = prior
337 .split('\n')
338 .filter(l => l.includes('"type":"decision"'))
339 .slice(-10)
340 .map(l => {
341 const d = JSON.parse(l) as Decision
342 return `${d.at.slice(11, 19)} ${LABEL[d.route].padEnd(10)} ${d.decidedBy.padEnd(8)} ${d.prompt.slice(0, 60)}`
343 })
344 return rows.length ? rows.join('\n') : `No decisions logged yet in ${path}.`
345}
346
347async function lastLoggedDecision($: EngineInterface): Promise<Decision | undefined> {
348 const path = S.opt.logPath
349 try {
350 const prior = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
351 const last = prior
352 .split('\n')
353 .filter(l => l.includes('"type":"decision"'))
354 .pop()
355 return last ? (JSON.parse(last) as Decision) : undefined
356 } catch {
357 return undefined
358 }
359}
360
361function explain(d: Decision | undefined, o: Override | undefined, fromLog = false): string {
362 const lines: string[] = []
363 if (o) lines.push(o.sticky ? `Locked to ${LABEL[o.route]} (/route auto to unlock).` : `Next turn: ${LABEL[o.route]} (one time).`)
364 if (!d) {
365 lines.push('No turn routed yet in this session.')
366 return lines.join('\n')
367 }
368 if (fromLog) lines.push(`From the log (the router reloaded since), routed at ${d.at.slice(11, 19)}:`)
369 lines.push(`Last turn: ${LABEL[d.route]} on ${d.model}${d.imageTier ? `, image tier ${d.imageTier}` : ''}.`)
370 lines.push(`Why: ${d.reason}.`)
371 const c = d.classification
372 if (c) {
373 lines.push(`Classifier: ${c.source}${c.jevModel ? ` (${c.jevModel})` : ''}, ${c.latencyMs} ms.`)
374 for (const r of ranked(c)) {
375 const p = c.probabilities[r] ?? 0
376 lines.push(` ${LABEL[r].padEnd(11)} ${'#'.repeat(Math.round(p * 20)).padEnd(20)} ${pct(p)}`)
377 }
378 lines.push(` security ${pct(c.securitySensitive)}, premium image ${pct(c.premiumImage)}`)
379 }
380 return lines.join('\n')
381}
382
383// ---------- status and log ----------
384
385function showStatus($: EngineInterface, d: Decision) {
386 const c = d.classification
387 // decidedBy 'jev' means "the classifier's pick", which is Haiku or the default when Jev is unavailable.
388 const who = d.decidedBy === 'jev' && c ? classifierName(c) : d.decidedBy
389 const conf = c && c.source !== 'default' ? ` ${pct(c.confidence)}` : ''
390 const tier = d.route === 'image' ? ` ${d.imageTier}` : ''
391 const lock = S.override?.sticky ? ' [locked]' : ''
392 $.ui.status(`${LABEL[d.route]}${tier} (${who}${conf})${lock}`)
393}
394
395async function writeLog($: EngineInterface, entry: Record<string, unknown>) {
396 const path = S.opt.logPath
397 try {
398 const prior = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
399 const lines = prior.split('\n').filter(l => l.length > 0)
400 lines.push(JSON.stringify(entry))
401 await $.fs.write(path, lines.slice(-MAX_LOG_LINES).join('\n') + '\n')
402 } catch (err) {
403 $.ui.log(`could not write the decision log: ${String(err)}`, { to: 'debug' })
404 }
405}
406
407// ---------- image tools ----------
408
409async function registerTools($: EngineInterface) {
410 const common = {
411 tier: {
412 type: 'string',
413 enum: ['flare', 'sunburst'],
414 description: 'flare: fast, the default. sunburst: premium, for polished final assets.',
415 },
416 size: { type: 'string', description: 'For example 1024x1024, 1536x1024, 1024x1536, or auto.' },
417 quality: { type: 'string', enum: ['low', 'medium', 'high', 'auto'] },
418 background: { type: 'string', enum: ['transparent', 'opaque', 'auto'] },
419 output_format: { type: 'string', enum: ['png', 'jpeg', 'webp'] },
420 filename: { type: 'string', description: 'Optional file name, without extension.' },
421 }
422 await $.tool.register({
423 name: 'generate_image',
424 description:
425 'Generate an image with OpenAI image generation and save it into the project. Takes up to a few minutes. Use this for ALL image creation: illustrations, logos, icons, photos, mockup images, pictures of diagrams. Never draw images by hand in code when this tool fits. Returns the saved file path.',
426 inputSchema: {
427 type: 'object',
428 properties: { prompt: { type: 'string', description: 'A full description of the image.' }, ...common },
429 required: ['prompt'],
430 },
431 isDeferred: false,
432 })
433 await $.tool.register({
434 name: 'edit_image',
435 description:
436 'Edit or restyle existing images with OpenAI image generation (takes up to a few minutes): change one element, swap a background, combine references. Use this for ALL image edits. Returns the saved file path.',
437 inputSchema: {
438 type: 'object',
439 properties: {
440 image_paths: { type: 'array', items: { type: 'string' }, description: 'Source image files in the project.' },
441 prompt: { type: 'string', description: 'What to change, and what to keep the same.' },
442 mask_path: { type: 'string', description: 'Optional PNG mask; transparent areas are edited.' },
443 ...common,
444 },
445 required: ['image_paths', 'prompt'],
446 },
447 isDeferred: false,
448 })
449 await $.tool.register({
450 name: 'daybreak',
451 description:
452 'Hand security work to OpenAI Daybreak through Codex: vulnerability review, secure code review, threat modeling, dependency risk, validating a security fix. It reads the project read-only, can take several minutes, and returns its findings. Works only on turns the user approved for Daybreak.',
453 inputSchema: {
454 type: 'object',
455 properties: {
456 task: { type: 'string', description: 'The full security task, with the context Daybreak needs.' },
457 paths: { type: 'array', items: { type: 'string' }, description: 'Files to start with, relative to the project.' },
458 },
459 required: ['task'],
460 },
461 isDeferred: false,
462 })
463}
464
465// Daybreak sends project code to OpenAI, so it runs only on a turn the user approved:
466// a yes to the dialog, or /route security.
467async function runDaybreak($: EngineInterface, a: Record<string, unknown>): Promise<string> {
468 if (S.active?.route !== 'security') {
469 return 'The user has not approved Daybreak for this turn, so nothing was sent to OpenAI. They can approve it with /route security.'
470 }
471 const task = String(a.task ?? '').trim()
472 if (!task) return 'daybreak needs a task.'
473 const paths = Array.isArray(a.paths) ? a.paths.map(String) : []
474 const out = await daybreakReview($, S.opt.codexPath, S.opt.daybreakProgram, S.opt.daybreakModel, DAYBREAK_REPLY, task, paths)
475 await writeLog($, {
476 type: 'daybreak',
477 at: new Date().toISOString(),
478 program: S.opt.daybreakProgram,
479 model: S.opt.daybreakModel || 'codex default',
480 ok: out.ok,
481 ...(out.ok ? {} : { error: out.error }),
482 })
483 return out.ok ? `OpenAI ${daybreakLabel()} reported:\n\n${out.report}` : `Daybreak failed: ${out.error}`
484}
485
486// The image tools run through Codex on the user's ChatGPT plan by default, or the Images API
487// when imageBackend is "api". There is no automatic fallback between them: the setting decides.
488async function runGenerate($: EngineInterface, a: Record<string, unknown>): Promise<string> {
489 const args = imageArgs(a)
490 let out
491 if (S.opt.imageBackend === 'codex') {
492 out = await codexImage($, S.opt.codexPath, S.opt.imageDir, args)
493 } else {
494 const key = await $.env.get('OPENAI_API_KEY')
495 if (!key) return 'OPENAI_API_KEY is not set, so no image was generated. Ask the user to set it, or to switch imageBackend to codex in /config.'
496 out = await generateImage($, key, S.opt.imageDir, args)
497 }
498 await writeLog($, { type: 'image', at: new Date().toISOString(), op: 'generate', backend: S.opt.imageBackend, ...out })
499 return out.ok ? `Saved ${out.path} (${out.model}).` : `Image generation failed: ${out.error}`
500}
501
502async function runEdit($: EngineInterface, a: Record<string, unknown>): Promise<string> {
503 const paths = Array.isArray(a.image_paths) ? a.image_paths.map(String) : []
504 if (paths.length === 0) return 'edit_image needs at least one path in image_paths.'
505 const args = { ...imageArgs(a), imagePaths: paths, maskPath: str(a.mask_path) }
506 let out
507 if (S.opt.imageBackend === 'codex') {
508 out = await codexImage($, S.opt.codexPath, S.opt.imageDir, args)
509 } else {
510 const key = await $.env.get('OPENAI_API_KEY')
511 if (!key) return 'OPENAI_API_KEY is not set, so no image was edited. Ask the user to set it, or to switch imageBackend to codex in /config.'
512 out = await editImage($, key, S.opt.imageDir, args)
513 }
514 await writeLog($, { type: 'image', at: new Date().toISOString(), op: 'edit', backend: S.opt.imageBackend, ...out })
515 return out.ok ? `Saved ${out.path} (${out.model}).` : `Image edit failed: ${out.error}`
516}
517
518function imageArgs(a: Record<string, unknown>) {
519 return {
520 prompt: String(a.prompt ?? ''),
521 tier: pickTier(a.tier),
522 size: str(a.size),
523 quality: str(a.quality),
524 background: str(a.background),
525 outputFormat: str(a.output_format),
526 filename: str(a.filename),
527 }
528}
529
530function pickTier(x: unknown): ImageTier {
531 if (x === 'flare' || x === 'sunburst') return x
532 return S.active?.imageTier ?? 'flare'
533}
534
535// ---------- helpers ----------
536
537// "Daybreak Blue", or "Daybreak Blue (gpt-6-sol)" when a model is pinned.
538function daybreakLabel(): string {
539 const name = daybreakName(S.opt.daybreakProgram)
540 return S.opt.daybreakModel ? `${name} (${S.opt.daybreakModel})` : name
541}
542
543function classifierName(c: Classification): string {
544 return c.source === 'jev' ? 'Jev' : c.source === 'fallback-haiku' ? 'Haiku' : 'default'
545}
546
547function pct(x: number): string {
548 return `${Math.round(x * 100)}%`
549}
550
551function str(x: unknown): string | undefined {
552 return typeof x === 'string' && x.length > 0 ? x : undefined
553}
554hooks/jev.ts 143 lines1import type { EngineInterface } from 'claude-code'
2
3export type Route = 'haiku' | 'opus' | 'fable' | 'image' | 'security'
4export const ROUTES: readonly Route[] = ['haiku', 'opus', 'fable', 'image', 'security']
5
6export type Classification = {
7 source: 'jev' | 'fallback-haiku' | 'default'
8 route: Route
9 confidence: number
10 probabilities: Partial<Record<Route, number>>
11 securitySensitive: number
12 premiumImage: number
13 jevModel?: string
14 latencyMs: number
15 error?: string
16}
17
18const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
19
20// What each route is for. Jev reads these as the Choice criteria, so they are the routing policy.
21export const ROUTE_CRITERIA: Record<Route, string> = {
22 haiku:
23 'Quick, low-stakes work: a lookup, a question about the code answered by reading one or two files, a summary, a commit message, formatting, renaming, a one-line fix, or a conversational reply.',
24 opus:
25 'Ordinary engineering work: writing or changing code across a few files, debugging with a clear trail, writing tests, reviewing a change, or a design question with a known shape.',
26 fable:
27 'The hardest work: architecture across a whole codebase, long multi-step agentic tasks, deep debugging with no clear lead, ambiguous problems needing extended reasoning, or high-stakes design decisions.',
28 image:
29 'The request is mainly to create, generate, draw, edit, restyle or retouch an image, picture, illustration, logo, icon, mockup image, photo or other graphic.',
30 security:
31 'Security work: finding or fixing vulnerabilities, secure code review, threat modeling, dependency risk, validating a security patch, exploits, malware, penetration testing or security tooling.',
32}
33
34/** Ask Jev which route fits. Throws on any transport or schema problem so the caller can fall back. */
35export async function classifyWithJev($: EngineInterface, apiKey: string, state: string): Promise<Classification> {
36 const started = await $.clock.now()
37 const res = await $.http.fetch(JEV_URL, {
38 method: 'POST',
39 headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
40 body: JSON.stringify({
41 model: 'jev-latest',
42 state,
43 questions: {
44 route: {
45 type: 'choice',
46 instructions: 'Which model tier should handle this request to a coding agent',
47 criteria: ROUTE_CRITERIA,
48 },
49 security: {
50 type: 'noul',
51 instructions:
52 'The request involves security work: exploits, vulnerabilities, malware, penetration testing, attack techniques or security tooling',
53 },
54 premium_image: {
55 type: 'noul',
56 instructions:
57 'The request asks for a polished, final, production-ready or campaign-quality image rather than a quick draft',
58 },
59 },
60 }),
61 })
62 if (!res.ok) throw new Error(`Jev HTTP ${res.status}: ${res.text.slice(0, 200)}`)
63
64 const body = JSON.parse(res.text) as {
65 model?: string
66 answers?: Record<string, { choice?: string; confidence?: number; probabilities?: Record<string, number>; noul?: number }>
67 }
68 const route = body.answers?.route
69 const choice = route?.choice
70 if (!route || !isRoute(choice)) throw new Error('Jev answered without a usable route')
71
72 const probabilities: Partial<Record<Route, number>> = {}
73 for (const r of ROUTES) {
74 const p = route.probabilities?.[r]
75 if (typeof p === 'number') probabilities[r] = p
76 }
77 return {
78 source: 'jev',
79 route: choice,
80 confidence: typeof route.confidence === 'number' ? route.confidence : (probabilities[choice] ?? 0),
81 probabilities,
82 securitySensitive: body.answers?.security?.noul ?? 0,
83 premiumImage: body.answers?.premium_image?.noul ?? 0,
84 jevModel: body.model,
85 latencyMs: (await $.clock.now()) - started,
86 }
87}
88
89/** Fallback when Jev is unavailable: one-word Haiku classification. Never throws. */
90export async function classifyWithHaiku($: EngineInterface, state: string, jevError: string): Promise<Classification> {
91 const started = await $.clock.now()
92 try {
93 const r = await $.model.complete({
94 model: 'haiku',
95 system:
96 'You route requests for a coding agent. Reply with exactly one word: haiku, opus, fable, image or security.\n' +
97 ROUTES.map(k => `${k}: ${ROUTE_CRITERIA[k]}`).join('\n'),
98 prompt: state,
99 maxTokens: 5,
100 timeoutMs: 8000,
101 })
102 const word = r.isAnswered ? r.text.trim().toLowerCase().replace(/[^a-z]/g, '') : ''
103 if (isRoute(word)) {
104 return {
105 source: 'fallback-haiku',
106 route: word,
107 // A one-word answer carries no calibrated confidence: treat it as unsure enough to show, sure enough not to ask.
108 confidence: 0.75,
109 probabilities: { [word]: 0.75 },
110 securitySensitive: 0,
111 premiumImage: 0,
112 latencyMs: (await $.clock.now()) - started,
113 error: jevError,
114 }
115 }
116 } catch {
117 // fall through to the default
118 }
119 return defaultClassification(jevError)
120}
121
122export function defaultClassification(error: string): Classification {
123 return {
124 source: 'default',
125 route: 'opus',
126 confidence: 1,
127 probabilities: { opus: 1 },
128 securitySensitive: 0,
129 premiumImage: 0,
130 latencyMs: 0,
131 error,
132 }
133}
134
135export function isRoute(x: unknown): x is Route {
136 return typeof x === 'string' && (ROUTES as readonly string[]).includes(x)
137}
138
139/** The routes ranked by Jev's probability, highest first. */
140export function ranked(c: Classification): Route[] {
141 return [...ROUTES].sort((a, b) => (c.probabilities[b] ?? 0) - (c.probabilities[a] ?? 0))
142}
143hooks/images.ts 157 lines1import type { EngineInterface } from 'claude-code'
2
3export type ImageTier = 'flare' | 'sunburst'
4
5export const IMAGE_MODELS: Record<ImageTier, string> = {
6 flare: 'gpt-image-2.5-flare',
7 sunburst: 'gpt-image-2.5-sunburst',
8}
9
10const OPENAI = 'https://api.openai.com/v1/images'
11
12export type ImageArgs = {
13 prompt: string
14 tier: ImageTier
15 size?: string
16 quality?: string
17 background?: string
18 outputFormat?: string
19 filename?: string
20}
21
22export type EditArgs = ImageArgs & { imagePaths: readonly string[]; maskPath?: string }
23
24export type ImageOutcome = { ok: true; path: string; model: string } | { ok: false; error: string }
25
26/** Text-to-image through the Images API, saved as a file under `dir`. */
27export async function generateImage($: EngineInterface, apiKey: string, dir: string, a: ImageArgs): Promise<ImageOutcome> {
28 const model = IMAGE_MODELS[a.tier]
29 const body: Record<string, unknown> = { model, prompt: a.prompt, n: 1 }
30 if (a.size) body.size = a.size
31 if (a.quality) body.quality = a.quality
32 if (a.background) body.background = a.background
33 if (a.outputFormat) body.output_format = a.outputFormat
34
35 const res = await $.http.fetch(`${OPENAI}/generations`, {
36 method: 'POST',
37 headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
38 body: JSON.stringify(body),
39 })
40 if (!res.ok) return { ok: false, error: `OpenAI HTTP ${res.status}: ${openAiMessage(res.text)}` }
41 return saveFirstImage($, res.text, dir, a, model)
42}
43
44/**
45 * Image edit. The Images edit endpoint takes multipart form data with the image files,
46 * which $.http.fetch (text bodies only) cannot send, so curl does the upload.
47 * The key goes to curl on stdin (`-H @-`), never on its command line.
48 */
49export async function editImage($: EngineInterface, apiKey: string, dir: string, a: EditArgs): Promise<ImageOutcome> {
50 const model = IMAGE_MODELS[a.tier]
51 for (const p of [...a.imagePaths, ...(a.maskPath ? [a.maskPath] : [])]) {
52 if (!(await $.fs.exists(p))) return { ok: false, error: `No such file: ${p}` }
53 }
54 const responseFile = `${dir}/.last-edit-response.json`
55 const sys = await windowsSystem32($)
56
57 const argv = [
58 sys ? `${sys}\\curl.exe` : 'curl', '-sS', '-X', 'POST', `${OPENAI}/edits`,
59 '-H', '@-',
60 '--create-dirs',
61 '-F', `model=${model}`,
62 '-F', `prompt=${a.prompt}`,
63 '-o', responseFile,
64 '-w', '%{http_code}',
65 ]
66 for (const p of a.imagePaths) argv.push('-F', `image[]=@${p}`)
67 if (a.maskPath) argv.push('-F', `mask=@${a.maskPath}`)
68 if (a.size) argv.push('-F', `size=${a.size}`)
69 if (a.quality) argv.push('-F', `quality=${a.quality}`)
70 if (a.background) argv.push('-F', `background=${a.background}`)
71 if (a.outputFormat) argv.push('-F', `output_format=${a.outputFormat}`)
72
73 const run = await $.process.run(argv, { stdin: `Authorization: Bearer ${apiKey}\n`, timeoutMs: 300_000 })
74 if (run.exitCode !== 0) return { ok: false, error: `curl failed: ${run.stderr.slice(0, 300)}` }
75 const text = await $.fs.read(responseFile)
76 const status = Number(run.stdout.trim())
77 if (status < 200 || status >= 300) return { ok: false, error: `OpenAI HTTP ${status}: ${openAiMessage(text)}` }
78 return saveFirstImage($, text, dir, a, model)
79}
80
81async function saveFirstImage($: EngineInterface, json: string, dir: string, a: ImageArgs, model: string): Promise<ImageOutcome> {
82 let b64: string | undefined
83 try {
84 b64 = (JSON.parse(json) as { data?: { b64_json?: string }[] }).data?.[0]?.b64_json
85 } catch {
86 return { ok: false, error: 'OpenAI answered with something that is not JSON' }
87 }
88 if (!b64) return { ok: false, error: 'OpenAI answered without image data' }
89
90 const path = await outputPath($, dir, a)
91
92 // $.fs.write writes text only, so a host tool decodes the base64 into the binary file.
93 const sys = await windowsSystem32($)
94 let run
95 if (sys) {
96 // The output path goes in through the environment, never spliced into the script.
97 run = await $.process.run(
98 [`${sys}\\WindowsPowerShell\\v1.0\\powershell.exe`, '-NoProfile', '-NonInteractive', '-Command', DECODE_PS],
99 { stdin: b64, env: { JEV_IMAGE_OUT: path } },
100 )
101 } else {
102 await $.process.run(['mkdir', '-p', dir])
103 run = await $.process.run(['openssl', 'base64', '-d', '-A', '-out', path], { stdin: b64 })
104 }
105 if (run.exitCode !== 0) return { ok: false, error: `Could not write ${path}: ${run.stderr.slice(0, 200)}` }
106 return { ok: true, path, model }
107}
108
109// Reads base64 on stdin and writes the bytes to $env:JEV_IMAGE_OUT, creating its folder.
110const DECODE_PS =
111 "$ErrorActionPreference = 'Stop'; try { " +
112 '$p = [IO.Path]::GetFullPath($env:JEV_IMAGE_OUT); ' +
113 '[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($p)); ' +
114 '[IO.File]::WriteAllBytes($p, [Convert]::FromBase64String([Console]::In.ReadToEnd())) ' +
115 '} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }'
116
117/**
118 * Windows has no mkdir or openssl executables, and what is on PATH depends on how
119 * Claude Code was launched, so on Windows the System32 tools are run by absolute path.
120 * Resolves undefined everywhere else.
121 */
122export async function windowsSystem32($: EngineInterface): Promise<string | undefined> {
123 if ((await $.env.get('OS')) !== 'Windows_NT') return undefined
124 return `${(await $.env.get('SystemRoot')) ?? 'C:\\Windows'}\\System32`
125}
126
127function openAiMessage(text: string): string {
128 try {
129 return (JSON.parse(text) as { error?: { message?: string } }).error?.message ?? text.slice(0, 300)
130 } catch {
131 return text.slice(0, 300)
132 }
133}
134
135export function slug(s: string): string {
136 return (
137 s
138 .toLowerCase()
139 .replace(/[^a-z0-9]+/g, '-')
140 .replace(/^-+|-+$/g, '')
141 .slice(0, 40) || 'image'
142 )
143}
144
145/** Where an image is saved: `<dir>/<filename or prompt slug>.<format>`, the same for every backend. */
146export async function outputPath($: EngineInterface, dir: string, a: ImageArgs): Promise<string> {
147 const ext = a.outputFormat === 'jpeg' ? 'jpg' : (a.outputFormat ?? 'png')
148 const name = sanitize(a.filename) ?? `${slug(a.prompt)}-${await $.clock.now()}`
149 return `${dir}/${name}.${ext}`
150}
151
152function sanitize(name: string | undefined): string | undefined {
153 if (!name) return undefined
154 const base = name.replace(/\.(png|jpe?g|webp)$/i, '').replace(/[^A-Za-z0-9._-]+/g, '-')
155 return base.length > 0 ? base : undefined
156}
157hooks/codex.ts 107 lines1import type { EngineInterface } from 'claude-code'
2import { type EditArgs, type ImageArgs, type ImageOutcome, outputPath, windowsSystem32 } from './images'
3
4/**
5 * Image work through the Codex CLI, billed to the user's ChatGPT plan instead of the API.
6 *
7 * `codex exec` is a whole agent run: it gets the request on stdin, generates with its
8 * image tool, and must save exactly one file at the path we name. We confirm that file
9 * exists afterwards rather than trusting its reply.
10 */
11
12const TIER_HINT = {
13 flare: 'a good-quality draft; favor speed',
14 sunburst: 'a polished, production-ready final asset; take care over detail',
15} as const
16
17// A Codex agent run, image generation included, takes about a minute; allow the API's maximum.
18const TIMEOUT_MS = 600_000
19
20export type CodexImageArgs = ImageArgs & Partial<Pick<EditArgs, 'imagePaths' | 'maskPath'>>
21
22export async function codexImage($: EngineInterface, codexPath: string, dir: string, a: CodexImageArgs): Promise<ImageOutcome> {
23 const launch = await codexLaunch($, codexPath)
24 if ('error' in launch) return { ok: false, error: launch.error }
25
26 const sources = [...(a.imagePaths ?? []), ...(a.maskPath ? [a.maskPath] : [])]
27 for (const p of sources) {
28 if (!(await $.fs.exists(p))) return { ok: false, error: `No such file: ${p}` }
29 }
30
31 // Writing Codex's reply file first creates the folder, which Codex needs as its working root.
32 const lastMessage = `${dir}/.codex-last-message.txt`
33 await $.fs.write(lastMessage, '')
34 const absDir = await absolute($, dir)
35 const path = await outputPath($, dir, a)
36 const absPath = `${absDir}/${path.slice(path.lastIndexOf('/') + 1)}`
37
38 // Sandboxed to the image folder: Codex can read the attached sources but write nowhere else.
39 const argv = [
40 ...launch.argv, 'exec',
41 '--skip-git-repo-check', '--ephemeral', '--color', 'never',
42 '-s', 'workspace-write',
43 '-C', absDir,
44 '-o', `${absDir}/.codex-last-message.txt`,
45 ]
46 for (const p of sources) argv.push('-i', await absolute($, p))
47 argv.push('-') // the request comes on stdin
48
49 const run = await $.process.run(argv, { stdin: instructions(a, absPath), timeoutMs: TIMEOUT_MS })
50 if (run.exitCode !== 0) return { ok: false, error: `codex exited ${run.exitCode}: ${tail(run.stderr)}` }
51 if (!(await $.fs.exists(path))) {
52 const said = await $.fs.read(lastMessage).catch(() => '')
53 return { ok: false, error: `Codex finished without saving ${path}. It said: ${said.slice(0, 300) || '(nothing)'}` }
54 }
55 return { ok: true, path, model: 'codex (ChatGPT plan)' }
56}
57
58function instructions(a: CodexImageArgs, absPath: string): string {
59 const editing = (a.imagePaths?.length ?? 0) > 0
60 const lines = [
61 editing
62 ? 'Use your image generation tool to edit the attached image(s) as described below.'
63 : 'Use your image generation tool to create the image described below.',
64 '',
65 `Request: ${a.prompt}`,
66 `Quality: ${TIER_HINT[a.tier]}.`,
67 ]
68 if (a.maskPath) lines.push('The last attached image is a mask: change only its transparent areas.')
69 if (a.size) lines.push(`Size: ${a.size}.`)
70 if (a.quality) lines.push(`Rendering quality: ${a.quality}.`)
71 if (a.background) lines.push(`Background: ${a.background}.`)
72 lines.push(
73 '',
74 `Save exactly one image file at this absolute path: ${absPath}`,
75 'Do not create, change or delete any other file.',
76 "Reply with only the saved file's absolute path.",
77 )
78 return lines.join('\n')
79}
80
81/**
82 * How to start Codex without a shell. On Windows the npm install is a .cmd/.ps1 shim,
83 * which can't be run by argv, so node.exe runs Codex's own codex.js by absolute path.
84 * `codexPath` overrides where codex.js (or, elsewhere, the codex executable) lives.
85 */
86export async function codexLaunch($: EngineInterface, codexPath: string): Promise<{ argv: string[] } | { error: string }> {
87 if (!(await windowsSystem32($))) {
88 if (!codexPath) return { argv: ['codex'] }
89 return { argv: codexPath.endsWith('.js') ? ['node', codexPath] : [codexPath] }
90 }
91 const node = `${(await $.env.get('ProgramFiles')) ?? 'C:\\Program Files'}\\nodejs\\node.exe`
92 const script = codexPath || `${(await $.env.get('APPDATA')) ?? ''}\\npm\\node_modules\\@openai\\codex\\bin\\codex.js`
93 if (!(await $.fs.exists(node))) return { error: `Node.js not found at ${node}, which Codex needs.` }
94 if (!(await $.fs.exists(script))) {
95 return { error: `Codex not found at ${script}. Install it (npm install -g @openai/codex) or set codexPath in /config.` }
96 }
97 return { argv: [node, script] }
98}
99
100export async function absolute($: EngineInterface, path: string): Promise<string> {
101 return (await $.fs.stat(path, { resolve: true })).realPath ?? path
102}
103
104export function tail(s: string): string {
105 return s.trim().split('\n').slice(-3).join(' | ').slice(0, 400)
106}
107hooks/daybreak.ts 77 lines1import type { EngineInterface } from 'claude-code'
2import { absolute, codexLaunch, tail } from './codex'
3
4/**
5 * Security work handed to OpenAI Daybreak through the Codex CLI, on the user's ChatGPT plan.
6 *
7 * `--cyber-access-program` asks the server for the Daybreak tier. Whether it is granted
8 * depends on the user's OpenAI account, not on anything sent from here.
9 *
10 * Codex runs read-only at the project root: it can read the code but change nothing, and
11 * Claude applies any fixes in the session under the user's normal permissions. The report
12 * is Codex's final message, which `-o` writes to `replyPath`.
13 */
14
15// A review can read a lot of code; allow the API's maximum.
16const TIMEOUT_MS = 600_000
17
18export type DaybreakProgram = 'standard' | 'daybreak_blue' | 'daybreak_red'
19
20export function daybreakName(p: DaybreakProgram): string {
21 return p === 'daybreak_red' ? 'Daybreak Red' : p === 'daybreak_blue' ? 'Daybreak Blue' : 'Daybreak (standard access)'
22}
23
24export type DaybreakOutcome = { ok: true; report: string } | { ok: false; error: string }
25
26export async function daybreakReview(
27 $: EngineInterface,
28 codexPath: string,
29 program: DaybreakProgram,
30 model: string,
31 replyPath: string,
32 task: string,
33 paths: string[],
34): Promise<DaybreakOutcome> {
35 const launch = await codexLaunch($, codexPath)
36 if ('error' in launch) return { ok: false, error: launch.error }
37
38 for (const p of paths) {
39 if (!(await $.fs.exists(p))) return { ok: false, error: `No such file: ${p}` }
40 }
41
42 // Clearing the reply file first creates its folder and drops any earlier report.
43 await $.fs.write(replyPath, '')
44 const argv = [
45 ...launch.argv, 'exec',
46 '--skip-git-repo-check', '--ephemeral', '--color', 'never',
47 '--cyber-access-program', program,
48 ...(model ? ['-m', model] : []), // empty: Codex's own default model
49 '-s', 'read-only',
50 '-C', await absolute($, '.'),
51 '-o', await absolute($, replyPath),
52 '-', // the task comes on stdin
53 ]
54
55 const run = await $.process.run(argv, { stdin: instructions(task, paths), timeoutMs: TIMEOUT_MS })
56 if (run.exitCode !== 0) return { ok: false, error: `codex exited ${run.exitCode}: ${tail(run.stderr)}` }
57 const report = (await $.fs.read(replyPath).catch(() => '')).trim()
58 if (!report) return { ok: false, error: 'Daybreak finished without a report.' }
59 return { ok: true, report }
60}
61
62function instructions(task: string, paths: string[]): string {
63 const lines = [
64 "Defensive security work on this repository, handed over from Claude Code by the repository's owner.",
65 '',
66 `Task: ${task}`,
67 ]
68 if (paths.length) lines.push('', 'Start with these files:', ...paths.map(p => `- ${p}`))
69 lines.push(
70 '',
71 'You are read-only: do not try to change any file.',
72 'Report each finding with its file and line, severity, why it matters, and a concrete fix (a diff where it helps).',
73 'If you find nothing, say so plainly.',
74 )
75 return lines.join('\n')
76}
77