SLOPSHOPPER

jev-router

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…

newguardcommandstatusprompttool
v0.1.0no licenseupdated 2026-10-15davidmclaughlinemail/jev-router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-router
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /route ⎿ jev-router: Last turn: Opus 5.5 on claude-opus-5-5. ⎿ jev-router: Why: Jev unavailable (TYPESAFE_API_KEY is not set); default route. ⎿ jev-router: Classifier: default, 0 ms. ⎿ jev-router: Opus 5.5 #################### 100% ⎿ jev-router: Haiku 5.5 0% ⎿ jev-router: Fable 5.1 0% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ jev-router: Opus 5.5 (default)
README

jev-router

A Claude Code mod that routes every turn to the right model.

  • Jev decides. Each prompt goes to TypeSafe's Jev, which returns a calibrated choice between Haiku, Opus, Fable and image work, plus two side checks (is this security work, is this a premium image).
  • You stay in control. When Jev's confidence is under your threshold, a question pops up showing its ranking and you pick. Fable always asks first (configurable). /route overrides, locks, and explains.
  • Routing is enforced, not suggested. A 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.
  • All graphics go to OpenAI. The mod gives Claude two tools, 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.

Routing policy

RouteModel (default id)Used for
haikuclaude-haiku-5-5Lookups, small edits, summaries, commit messages, chat
opusclaude-opus-5-5Ordinary engineering, and the fallback for everything
fableclaude-fable-5-1Architecture, long agentic work, deep ambiguous problems
imageHaiku + Codex image generation (or gpt-image-2.5-flare / -sunburst on the API)Creating or editing any image
securityOpus + OpenAI Daybreak Blue through Codex (--cyber-access-program daybreak_blue), read-onlyVulnerability review, threat modeling, secure code review, after you say yes

Rules on top of Jev:

  • Security work (the classifier picks 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.
  • The daybreak tool refuses on any turn you didn't approve (a yes, or /route security), so your code never goes to OpenAI unasked.
  • Jev confidence below confidenceThreshold (default 0.7): you are asked.
  • Jev picks Fable confidently and confirmFable is on: you are asked.
  • Jev unreachable or no key: Haiku classifies with a one-word answer; if that fails too, Opus.
  • Prompts typed while a turn is running keep the running turn's model.

The route descriptions Jev scores against live in hooks/jev.ts (ROUTE_CRITERIA). Editing them is how you tune the policy.

Requirements

  • Claude Code v2.1.287 or later, in the terminal or the Code tab of the Desktop app (mods run there)
  • TYPESAFE_API_KEY: a Jev key from console.typesafe.ai (early access). Without it the router still works on the Haiku fallback.
  • For images, either backend:
  • codex (default): the Codex CLI, recent enough for OpenAI's current models (0.162 or later; older versions are refused), signed in with 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.
  • api: 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).
  • For Daybreak: the same Codex CLI. The mod asks for 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.

Install

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.

Use

CommandEffect
/route or /route whyThe 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 fableEvery turn on that route until /route auto
/route autoHand control back to Jev
/route logThe 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%).

Settings

Change these in /config (each is a row there) or under pluginConfigs in settings, keyed jev-router@inline for a --plugin-dir install:

SettingDefault
haikuModel, opusModel, fableModelclaude-haiku-5-5, claude-opus-5-5, claude-fable-5-1Model ids per route
confidenceThreshold0.7Ask below this
confirmFabletrueAsk before any Fable turn
routeSubagentstrueSubagents without a model follow the route
imageDirassets/generatedWhere images are saved
imageBackendcodexcodex (ChatGPT plan, through the Codex CLI) or api (Images API, OPENAI_API_KEY). No automatic fallback between them
codexPathemptyWhere Codex is, if not found automatically: codex.js on Windows, the codex executable elsewhere
daybreakProgramdaybreak_blueCyber access program requested for approved security work: daybreak_blue, daybreak_red (approved partners only) or standard
daybreakModelemptyEmpty uses Codex's default model; otherwise the model id passed with -m
logPath.jev-router/decisions.jsonlDecision log (add it to .gitignore)

The decision log

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.

Things to know

  • Model switches cost cache. Prompt caching is per model, so a turn routed to a different model than the previous one re-reads the conversation without the cache. The route holds for a whole turn to limit this; frequent switching in a long session costs more than it looks.
  • Jev sees the prompt plus the tail of Claude's last reply, so a short follow-up like "yes, do that" routes with its context.
  • Codex images take about a minute. Each one is a full Codex agent run (generation plus saving), sandboxed to 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.
  • Daybreak needs a working Codex sandbox. Codex reads your code by running shell commands inside its sandbox. On Windows, a broken 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.
  • Image size (api backend). Image files are decoded from the API's base64 output; very large outputs (over about 4 MB of base64) can exceed the mod API's read limit on edits.
  • Trust. A mod runs with your permissions and reads the two API keys from your environment. Keep it in a folder or marketplace you control.

Tests

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.

Source 5 files
hooks/register.ts 554 lines
1import 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}
554
hooks/jev.ts 143 lines
1import 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}
143
hooks/images.ts 157 lines
1import 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}
157
hooks/codex.ts 107 lines
1import 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}
107
hooks/daybreak.ts 77 lines
1import 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