Codex inside Claude Code: it picks up the chat when your Claude limit runs out, talks with you through Codex's live voice, makes images Claude can ask for, and…


Codex inside Claude Code. Nightshift is a Claude Code mod that puts OpenAI's Codex one click away in the same window:
/codex <prompt> answers one message with Codex, whatever the mode.
PATH, signed in (codex login). A ChatGPT plan works; no OpenAI API key is needed.PATH, for Nightshift's two small helper scripts.In Claude Code:
/plugin marketplace add hacksurvivor/claude-mods
/plugin install nightshift@hacksurvivor
/reload-plugins
/nightshift auto | codex | claude: Codex only when Claude's limit is used up (the default), Codex for every message, or never./nightshift effort <level>, /nightshift model <name>, /nightshift tools claude | codex | auto./codex <prompt>: one message to Codex. Start it with $imagegen for an image.Nightshift is open about everything it does on your machine and what leaves it:
PATH through your login shell: codex exec in the project folder with Codex's workspace-write sandbox, and codex app-server for voice. Codex then works with your own Codex login and settings./codex; the brief Claude writes when it calls the codex tool; and during Talk, your voice, which Codex's voice helper streams to OpenAI. The microphone is used only while Talk is on.~/.codex/sessions (never your messages there), the model list in ~/.codex/models_cache.json, the default model and effort in ~/.codex/config.toml, and the images Codex saves in ~/.codex/generated_images, to show them (it makes small JPEG previews with macOS sips). It never reads ~/.codex/auth.json or any credential.hooks/voice.mjs (the voice bridge) and hooks/browser-mcp.mjs (the browser bridge Codex uses). They talk to Nightshift over Unix sockets in your private temp folder ($TMPDIR). Neither opens a network port.All on your computer. Nothing is downloaded or installed, and no package manager runs.
| Program | When | Why |
|---|---|---|
codex exec --json … and codex exec resume …, through /bin/zsh -lc 'exec codex "$@"' | When Codex picks up the chat, for /codex, and when Claude calls the codex tool | To have Codex do the work. The login shell finds codex on your PATH the way your terminal does. The arguments are fixed apart from the prompt, the Codex model and effort, images you attach, and the settings that connect Codex to the browser bridge. |
node hooks/voice.mjs, through /bin/zsh -lc 'exec node "$@"' | While Talk is on | The voice bridge. It runs codex app-server and the voice helper that ships with the Codex CLI. |
node hooks/browser-mcp.mjs | Started by Codex during runs Nightshift begins | The browser bridge: Codex's way into Claude's browser pane. |
/bin/zsh -c with ls, grep and tail | At session start, after each Codex run, and every 5 minutes | To read the newest usage-limit line from ~/.codex/sessions, for the 7-day meter. |
/bin/zsh -c with cat and awk | At session start | To read Codex's model list and the two settings it uses (default model and effort) from ~/.codex. |
/bin/zsh -c with stat, and sips with base64 | After a Codex run that made images | To find the PNGs Codex saved for that run and make small JPEG previews (in a temporary file deleted right after). |
/usr/bin/getconf DARWIN_USER_TEMP_DIR | When a bridge starts | To find your private temp folder for the bridges' sockets. |
Nightshift's own requests ($.http.fetch) go only to http://localhost, over Unix sockets in your private temp folder, to its two bridges. They never leave your computer:
GET /next (the next step Codex asks for) and POST /result (its result).POST /mute, POST /end, POST /route (Codex or Claude does the work) and POST /speak (Claude's answer, for the voice to read out).Data leaves your computer only through the Codex CLI, to OpenAI, as listed above.
While a Codex run or a call that Nightshift started is going, Nightshift calls the tools of Claude's browser pane (navigate, get_page_text, read_page, find, computer, form_input, tabs_context, tabs_create) on Codex's behalf: one call for each step Codex asks for, asked or not as described above. Before Codex's first step and each page it opens, Nightshift also calls tabs_context to see whether you can watch: if the pane isn't open, it opens Codex's page with preview_start, which shows the pane; if the pane is hidden behind something else, it shows a toast saying ⌘⇧B brings it back. It calls no other tools.
/codex <prompt>: your prompt, and Codex answers it.When Claude answers again after Codex covered for it, Nightshift adds one note to Claude's context. The note says how many messages Codex answered and which files it changed.
session.start: registers /nightshift, /codex and the codex tool, and reads your settings and Codex's limits.prompt.submit: adds the note above when Claude takes back over from Codex. Everything else passes through unchanged.turn.start, turn.step, turn.complete: when Codex covers for Claude, or a voice exchange is kept, turn.step answers the turn itself instead of Claude. Otherwise the turn goes to Claude unchanged. turn.complete sends Claude's answer to the voice for work you passed to Claude, and retries a limit-hit message with Codex.tool.call: answers Nightshift's own codex tool. When Tools is set to Codex, it refuses calls to Claude's browser tools (mcp__Claude_Browser__*, mcp__claude-in-chrome__*) and computer-use tools (mcp__computer-use__*), telling Claude to use Codex instead. Every other tool call passes through unchanged.tool.describe: only when Tools is set to Codex. It describes those same browser and computer-use tools as off and moves them behind tool search, and adds to the codex tool's description that it takes that work.ui.render: draws the strip, Codex's replies, voice rows, image cards and the Allow row. It changes nothing else.command.run: serves /nightshift and /codex.Nightshift uses your Codex login only by running the Codex CLI. It never reads, stores or sends a credential, so there is no key or token to configure.
>_ header are OpenAI's; parts of the Codex CLI's art and shimmer are ported under Apache-2.0 (see NOTICE).Apache-2.0. See LICENSE and NOTICE.
hooks/register.tsx 1562 lines1import type { EngineInterface, Register, RenderElement, SessionMessage, TurnStepChunk, TurnStepResult } from 'claude-code'
2
3import {
4 BROWSER_NOTE,
5 browserConfig,
6 buildPrompt,
7 CODEX_LIMITS_SCRIPT,
8 CODEX_SETUP_SCRIPT,
9 codexArgv,
10 exhausted,
11 kindLabel,
12 readCodexDefaults,
13 currentWeek,
14 readCodexLimits,
15 readEvent,
16 readModels,
17 resetLabel,
18 weekElapsed,
19} from './codex'
20import type { Brief, CodexLimits, CodexModel, Limit } from './codex'
21import {
22 bulletColor,
23 CODEX_EFFORTS,
24 EFFORT_LABELS,
25 effortsFor,
26 effortWithin,
27 duration,
28 HEADER,
29 isCodexReply,
30 limitTiles,
31 liveLine,
32 logoDots,
33 readReply,
34 shimmerColors,
35} from './look'
36import type { CodexEffort } from './look'
37import {
38 browserStep,
39 CODEX_TOOL_DESCRIPTION,
40 CODEX_TOOL_SCHEMA,
41 HANDED_TOOL_NOTE,
42 HANDED_TOOLS,
43 hear,
44 imageCard,
45 imagesScript,
46 isLookOnly,
47 isVoiceReply,
48 liveWave,
49 PREVIEW_SCRIPT,
50 QUIET,
51 readBridge,
52 readCall,
53 readPreview,
54 speakable,
55 splitImages,
56 SPOKE,
57 talkState,
58 TOOLS_TO_CODEX,
59 VOICE_HEADER,
60 voiceArgv,
61 VoiceFeed,
62} from './talk'
63import type { BrowserAccess, BrowserCall, Preview, Talk, Worker } from './talk'
64
65// While Claude's usage limit is used up, Codex answers in the same chat:
66// a turn.step hook answers the request itself instead of sending it. When
67// the limit resets, Claude answers again and is told what Codex did.
68
69type Mode = 'auto' | 'codex' | 'claude'
70
71async function limitsNow($: EngineInterface): Promise<{ limits: Limit[]; now: number }> {
72 const [usage, now] = await Promise.all([$.session.usage(), $.clock.now()])
73
74 return { limits: usage.rateLimits, now }
75}
76
77// A line in the transcript the model never reads; a toast where that fails,
78// so a missing note never stops the answer.
79async function notice($: EngineInterface, text: string): Promise<void> {
80 try {
81 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
82 } catch {
83 $.ui.toast(text)
84 }
85}
86
87// Codex's own limits, read from its newest session logs; never its messages.
88async function readCodex($: EngineInterface, now: number): Promise<CodexLimits | undefined> {
89 try {
90 const { stdout } = await $.process.run(['/bin/zsh', '-c', CODEX_LIMITS_SCRIPT], { timeoutMs: 5000 })
91
92 return readCodexLimits(stdout.trim(), now)
93 } catch {
94 return undefined
95 }
96}
97
98// The same, stamped with when it was read.
99async function readCodexNow($: EngineInterface): Promise<{ read: CodexLimits | undefined; now: number }> {
100 const now = await $.clock.now()
101
102 return { read: await readCodex($, now), now }
103}
104
105// Which models Codex offers and what it uses by default, from its own files.
106async function readCodexSetup($: EngineInterface): Promise<{ models: CodexModel[]; model?: string; effort?: string }> {
107 try {
108 const { stdout } = await $.process.run(['/bin/zsh', '-c', CODEX_SETUP_SCRIPT], { timeoutMs: 5000 })
109 const [cache = '', config = ''] = stdout.split('@@config@@')
110
111 return { models: readModels(cache.trim()), ...readCodexDefaults(config) }
112 } catch {
113 return { models: [] }
114 }
115}
116
117// Codex's effort: Light (low) through Max; "light" names low.
118function asEffort(value: unknown): CodexEffort | undefined {
119 const word = value === 'light' ? 'low' : value
120
121 return CODEX_EFFORTS.find(effort => effort === word)
122}
123
124// A small JPEG of a Codex image for its card in the chat.
125async function previewOf($: EngineInterface, path: string): Promise<Preview | undefined> {
126 try {
127 const { exitCode, stdout } = await $.process.run(['/bin/zsh', '-c', PREVIEW_SCRIPT, 'nightshift', path], { timeoutMs: 20000 })
128
129 return exitCode === 0 ? readPreview(stdout) : undefined
130 } catch {
131 return undefined
132 }
133}
134
135// Asks for the previews a card needs; each redraws the chat once it is ready.
136function wantPreviews($: EngineInterface, paths: readonly string[], previews: Map<string, Preview | null | undefined>): void {
137 for (const path of paths) {
138 if (!previews.has(path)) {
139 previews.set(path, undefined)
140 void previewOf($, path).then(preview => {
141 previews.set(path, preview ?? null)
142 $.ui.invalidate('ui.render')
143 })
144 }
145 }
146}
147
148// The images a Codex thread saved during a run.
149async function newImages($: EngineInterface, thread: string, sinceMs: number): Promise<string[]> {
150 try {
151 const { stdout } = await $.process.run(imagesScript(thread, sinceMs / 1000 - 2), { timeoutMs: 10000 })
152
153 return stdout.split('\n').filter(line => line.startsWith('/'))
154 } catch {
155 return []
156 }
157}
158
159// One Codex run for Claude's `codex` tool: its reply, and the images it made.
160async function runCodexTool(
161 $: EngineInterface,
162 input: { prompt: string; images?: readonly string[] },
163 choice: { model?: string; effort?: string; config?: readonly string[] },
164): Promise<{ text: string; images: string[]; previews: Preview[] }> {
165 const [cwd, startedAt] = await Promise.all([$.session.cwd(), $.clock.now()])
166 const { stdout, stderr } = await $.process.run(codexArgv(undefined, input.prompt, { ...choice, images: input.images }), {
167 cwd,
168 timeoutMs: 600000,
169 })
170 let thread: string | undefined
171 let error: string | undefined
172 const said: string[] = []
173
174 for (const line of stdout.split('\n')) {
175 const event = readEvent(line)
176
177 thread = event.threadId ?? thread
178 error = event.error ?? error
179
180 if (event.show !== undefined && event.isActivity !== true) {
181 said.push(event.show)
182 }
183 }
184
185 const images = thread === undefined ? [] : await newImages($, thread, startedAt)
186 const previews = (await Promise.all(images.map(path => previewOf($, path)))).filter(
187 (preview): preview is Preview => preview !== undefined,
188 )
189 const reason = error ?? (said.length === 0 ? stderr.trim().split('\n').at(-1) : undefined)
190
191 return {
192 text:
193 (said.join('\n\n') || (reason ? `Codex couldn't finish: ${reason}` : 'Codex finished without a reply.')) +
194 (images.length > 0 ? `\n\nImages saved:\n${images.map(path => `- ${path}`).join('\n')}` : ''),
195 images,
196 previews,
197 }
198}
199
200// While a Codex run lasts, carries each of its browser steps into Claude's
201// browser pane: the bridge holds the step, `decide` says whether it may run,
202// and the pane's own tool does it.
203// Whether Claude's browser pane is showing: it reports "displayed", "hidden"
204// (something else fills the side panel) or not open yet.
205async function paneState($: EngineInterface): Promise<PaneState> {
206 try {
207 const { content } = await $.mcp.call('Claude_Browser', 'tabs_context', {})
208 const text = content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
209
210 return /isn.t open|not open|browserOpen"?\s*:\s*false/i.test(text) ? 'closed' : /\bhidden\b/i.test(text) ? 'hidden' : 'shown'
211 } catch {
212 return 'shown'
213 }
214}
215
216async function pumpBrowser(
217 $: EngineInterface,
218 socket: string,
219 decide: (call: BrowserCall) => Promise<boolean>,
220 isRunning: () => boolean,
221 onPane: (state: PaneState) => void,
222): Promise<void> {
223 let isChecked = false
224
225 while (isRunning()) {
226 let call: BrowserCall | undefined
227
228 try {
229 const next = await $.http.fetch('http://localhost/next', { method: 'GET', socketPath: socket })
230
231 call = next.status === 200 ? readCall(next.text) : undefined
232 } catch {
233 // The bridge starts with Codex's run; until then its socket is not there.
234 await $.clock.sleep(300)
235 continue
236 }
237
238 if (call === undefined) {
239 continue
240 }
241
242 // Before Codex's first step and each page it opens, see whether you can
243 // watch: a closed pane opens at the page (navigate alone would not show
244 // it); a hidden one is yours to bring back, so Nightshift says how.
245 let tool = call.tool
246 let args = call.args
247
248 if (!isChecked || call.tool === 'navigate') {
249 isChecked = true
250
251 const state = await paneState($)
252
253 onPane(state)
254
255 if (state === 'closed' && call.tool === 'navigate' && typeof call.args.url === 'string' && /^https?:/.test(call.args.url)) {
256 tool = 'preview_start'
257 args = { url: call.args.url }
258 }
259 }
260
261 const result = (await decide(call))
262 ? await $.mcp.call('Claude_Browser', tool, args).catch((error: unknown) => ({
263 content: [{ type: 'text', text: `Claude's browser pane refused: ${error instanceof Error ? error.message : String(error)}` }],
264 isError: true,
265 }))
266 : {
267 content: [{ type: 'text', text: 'The user declined this browser step. Do not retry it; say what you needed it for.' }],
268 isError: true,
269 }
270
271 await $.http
272 .fetch('http://localhost/result', { method: 'POST', socketPath: socket, body: JSON.stringify({ id: call.id, result }) })
273 .catch(() => undefined)
274 }
275}
276
277type PaneState = 'closed' | 'hidden' | 'shown'
278
279// The user's own temp folder for the helpers' sockets (macOS gives each user
280// one), so no other account on the machine can reach them.
281async function privateDir($: EngineInterface): Promise<string> {
282 try {
283 const { stdout } = await $.process.run(['/usr/bin/getconf', 'DARWIN_USER_TEMP_DIR'], { timeoutMs: 5000 })
284 const dir = stdout.trim()
285
286 return dir.startsWith('/') ? dir.replace(/\/?$/, '/') : '/tmp/'
287 } catch {
288 return '/tmp/'
289 }
290}
291
292// The strip's limit meter: room for 8px tiles, two a day, and the reset time.
293const STRIP_METER_WIDTH = 300
294// Under this much of the week left, the strip's button shows the number.
295const LOW_WEEK_LEFT = 20
296
297// "Codex Light", with the week left once it runs low.
298function stripLabel(effort: CodexEffort, left: number | undefined): string {
299 const low = left !== undefined && left < LOW_WEEK_LEFT ? ` · ${left}%` : ''
300
301 return `Codex ${EFFORT_LABELS[effort]}${low}`
302}
303
304// The key a finished reply's meta line is kept under until it is drawn.
305function keyOf(text: string): string {
306 return text.replace(/\s+/g, ' ').trim().slice(0, 160)
307}
308
309function brief(message: SessionMessage): Brief {
310 return { role: message.role, text: message.text, tools: message.toolUses.map(use => use.tool) }
311}
312
313export const register: Register = on => {
314 let mode: Mode = 'auto'
315 let threadId: string | undefined
316 let seen = 0
317 let codexTurns = 0
318 let isCodexActive = false
319 const files = new Set<string>()
320 const prompts = new Map<string, string>()
321 let lastPrompt: string | undefined
322 let isRetrying = false
323 let isCodexOn = false
324 let lastOut: Limit | undefined
325 let working: { startedAt: number; step: string } | undefined
326 const metas = new Map<string, string>()
327 let ticker: (() => void) | undefined
328 let refresher: (() => void) | undefined
329 let codexLimits: CodexLimits | undefined
330 // Codex's live voice: the strip's state, the bridge's socket, and each spoken
331 // exchange waiting to be kept in the chat as your words and Codex's reply.
332 let talk: Talk = QUIET
333 // The strip above the box opens from its button in the footer; it also
334 // shows by itself while Codex works, talks, asks, or stands in for Claude.
335 let isStripOpen = false
336 let talkSocket: string | undefined
337 // Codex's spoken replies, each waiting for its chat row by the words that open it.
338 const feeds = new Map<string, VoiceFeed[]>()
339 // Rows the voice put in the chat: what you said to Codex, what you asked Claude
340 // for, or the hidden row a reply Codex speaks on its own opens with.
341 const voiceRows = new Map<string, Worker | 'hidden'>()
342 // Who does the work you ask for out loud; Claude's tasks get its answer read back.
343 let voiceWorker: Worker = 'codex'
344 // Who does browser, Mac-app, image and document work: Claude's own tools, or Codex.
345 let toolsWorker: Worker = 'claude'
346 // Codex's steps in Claude's browser pane: asked here first, or run as they come.
347 let browserAccess: BrowserAccess = 'ask'
348 const asks: { call: BrowserCall; answer: (allowed: boolean) => void; allowRest: () => void }[] = []
349 // Whether Claude's browser pane is hidden while Codex browses in it.
350 let isPaneHidden = false
351 // Says once per run when Codex browses where you can't see it; the strip
352 // and the Allow row show the same hint until the pane is back.
353 const paneWatcher = (toast: (text: string) => void, redraw: () => void) => {
354 let isTold = false
355
356 return (state: 'closed' | 'hidden' | 'shown') => {
357 const wasHidden = isPaneHidden
358
359 isPaneHidden = state === 'hidden'
360
361 if (isPaneHidden && !isTold) {
362 isTold = true
363 toast("Codex is browsing in Claude's browser. Press ⌘⇧B to watch.")
364 }
365
366 if (wasHidden !== isPaneHidden) {
367 redraw()
368 }
369 }
370 }
371 const claudeTasks = new Set<string>()
372 let talkRun: ReturnType<EngineInterface['process']['spawn']> | undefined
373 // Codex's one-off runs: `/codex <prompt>`, answered by Codex whatever the mode.
374 const once = new Set<string>()
375 // Image card previews by path: absent until asked, undefined while loading.
376 const previews = new Map<string, Preview | null | undefined>()
377 // Each image's card, built once: its picture is ~100 KB of SVG, and every
378 // redraw re-runs the chat's rows.
379 const cards = new Map<string, ReturnType<typeof imageCard>>()
380 const cardFor = (path: string, preview: Preview | null | undefined) => {
381 if (!preview) {
382 return undefined
383 }
384
385 const card = cards.get(path) ?? imageCard(preview)
386
387 cards.set(path, card)
388
389 return card
390 }
391 const toolImages = new Map<string, string[]>()
392 let codexReadAt = 0
393 let models: CodexModel[] = []
394 let codexModel: string | undefined
395 let codexEffort: CodexEffort = 'medium'
396
397 // The levels the chosen model takes, so the strip never offers one it refuses.
398 const offered = (): CodexEffort[] => effortsFor(models.find(model => model.slug === codexModel)?.efforts)
399
400 // Codex's limit moves when Codex runs here or anywhere else on this Mac.
401 const keepCodex = ({ read, now }: { read: CodexLimits | undefined; now: number }): void => {
402 codexReadAt = now
403 codexLimits = read ?? codexLimits
404 }
405
406 // Whether Codex answers the next message; the strip above the box says so.
407 const settle = (limits: readonly Limit[], now: number): void => {
408 lastOut = exhausted(limits, now)
409 isCodexOn = mode === 'codex' || (mode === 'auto' && lastOut !== undefined)
410 }
411
412 on('session.start', async ($, e, next) => {
413 await $.command.register({
414 name: 'nightshift',
415 description: 'Hand the chat to Codex when your Claude limit runs out, or switch by hand',
416 argumentHint: 'auto | codex | claude | effort <level> | model <name> | tools codex|claude',
417 })
418 await $.command.register({
419 name: 'codex',
420 description: 'Ask Codex once, here in the chat: $imagegen for an image, or any of its tools and plugins',
421 argumentHint: '<prompt>',
422 })
423
424 try {
425 await $.tool.register({ name: 'codex', description: CODEX_TOOL_DESCRIPTION, inputSchema: CODEX_TOOL_SCHEMA })
426 } catch {
427 // Without the tool, /codex still reaches Codex.
428 }
429
430 const [setup, savedModel, savedEffort, savedWorker, savedTools, savedAccess] = await Promise.all([
431 readCodexSetup($),
432 $.store.get('codexModel'),
433 $.store.get('codexEffort'),
434 $.store.get('voiceWorker'),
435 $.store.get('toolsWorker'),
436 $.store.get('browserAccess'),
437 ])
438
439 voiceWorker = savedWorker === 'claude' ? 'claude' : 'codex'
440 toolsWorker = savedTools === 'codex' ? 'codex' : 'claude'
441 browserAccess = savedAccess === 'auto' ? 'auto' : 'ask'
442
443 models = setup.models
444 codexModel = typeof savedModel === 'string' ? savedModel : setup.model
445 codexEffort = asEffort(savedEffort) ?? asEffort(setup.effort) ?? 'medium'
446
447 const { limits, now } = await limitsNow($)
448
449 codexLimits = await readCodex($, now)
450 codexReadAt = now
451 settle(limits, now)
452 // Every five minutes, so use in the Codex app shows here too.
453 refresher?.()
454 refresher = $.clock.every(5 * 60 * 1000, () => {
455 void readCodexNow($).then(read => {
456 keepCodex(read)
457 $.ui.invalidate('ui.render')
458 })
459 }).cancel
460 // The strip above the box shows Codex now; clear the old status line.
461 $.ui.status(undefined)
462
463 return next(e)
464 })
465
466 // Claude answers again after Codex did: tell it what happened meanwhile.
467 on('prompt.submit', async ($, e, next) => {
468 if (codexTurns === 0 || mode === 'codex') {
469 return next(e)
470 }
471
472 const { limits, now } = await limitsNow($)
473
474 if (mode === 'auto' && exhausted(limits, now) !== undefined) {
475 return next(e)
476 }
477
478 const changed = [...files]
479 const note =
480 `While Claude was unavailable, Codex (OpenAI) answered the last ${codexTurns} ` +
481 `message${codexTurns === 1 ? '' : 's'} in this conversation; its replies are above. ` +
482 (changed.length > 0 ? `It changed: ${changed.join(', ')}. ` : 'It changed no files. ') +
483 'Check its work where it matters before building on it.'
484
485 await notice(
486 $,
487 `Claude is back. It got a summary of what Codex did: ${codexTurns} repl${codexTurns === 1 ? 'y' : 'ies'}, ` +
488 `${changed.length} file${changed.length === 1 ? '' : 's'} changed.`,
489 )
490 codexTurns = 0
491 files.clear()
492 isCodexActive = false
493
494 return next({ ...e, context: [...(e.context ?? []), note] })
495 })
496
497 on('turn.start', ($, e, next) => {
498 prompts.set(e.turnId, e.text)
499 lastPrompt = e.text
500
501 return next(e)
502 })
503
504 on('turn.step', async function* ($, e, next) {
505 if (e.agentId !== undefined || e.index > 0) {
506 return yield* next(e)
507 }
508
509 // A voice row: Codex's spoken reply streams in as Codex talks; no model runs.
510 const said = prompts.get(e.turnId)
511 const queued = said === undefined ? undefined : feeds.get(said)
512 const feed = queued?.shift()
513
514 if (feed !== undefined) {
515 if (queued?.length === 0 && said !== undefined) {
516 feeds.delete(said)
517 }
518
519 let answer = `${VOICE_HEADER}\n\n`
520
521 yield { kind: 'text', index: 0, text: answer }
522
523 for await (const piece of feed.read()) {
524 answer += piece
525 yield { kind: 'text', index: 0, text: piece }
526 }
527
528 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
529
530 const result: TurnStepResult = { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: 'end_turn', usage: null }
531
532 return result
533 }
534
535 const { limits, now } = await limitsNow($)
536 const out = exhausted(limits, now)
537 const isOnce = said !== undefined && once.delete(said)
538 const isCodex = isOnce || mode === 'codex' || (mode === 'auto' && out !== undefined)
539
540 settle(limits, now)
541 $.ui.invalidate('ui.render')
542
543 if (now - codexReadAt > 60000) {
544 codexReadAt = now
545 void readCodexNow($).then(read => {
546 keepCodex(read)
547 $.ui.invalidate('ui.render')
548 })
549 }
550
551 if (!isCodex) {
552 return yield* next(e)
553 }
554
555 const text = prompts.get(e.turnId) ?? ''
556 const [cwd, read] = await Promise.all([$.session.cwd(), $.session.messages()])
557 const rows: readonly SessionMessage[] = Array.isArray(read) ? read : []
558 const last = rows.at(-1)
559 const isNewPrompt = last?.role === 'user' && last.text.trim() === text.trim()
560 const before = isNewPrompt ? rows.slice(0, -1) : rows
561 const isFirst = threadId === undefined
562 const context = (isFirst ? before : before.slice(Math.min(seen, before.length))).map(brief)
563 const why = isOnce
564 ? 'the user asked for you by name with /codex'
565 : out !== undefined
566 ? `the user's Claude ${kindLabel(out.kind)} usage limit ran out`
567 : 'the user switched this conversation to you'
568
569 if (!isCodexActive && !isOnce) {
570 const handoff =
571 out !== undefined
572 ? `Claude's ${kindLabel(out.kind)} limit is used up (resets ${resetLabel(out.resetsAt)}). `
573 : 'You switched this chat to Codex. '
574
575 await notice(
576 $,
577 `${handoff}Codex picked up this conversation` +
578 (isFirst ? ` with the last ${Math.min(context.length, 20)} messages` : '') +
579 ' · your ChatGPT login',
580 )
581 isCodexActive = true
582 }
583
584 seen = rows.length
585 // A one-off is the user's own ask; Claude is not told it was away.
586 codexTurns += isOnce ? 0 : 1
587
588 const prompt = buildPrompt({ isFirst, cwd, why, context, text })
589 const run = $.process.spawn({
590 argv: codexArgv(threadId, prompt, { model: codexModel, effort: effortWithin(codexEffort, offered()) }),
591 cwd,
592 input: '',
593 })
594 const header: TurnStepChunk = { kind: 'text', index: 0, text: `${HEADER}\n\n` }
595 let answer = ''
596 let pending = ''
597 let wasActivity = false
598 let error: string | undefined
599 let stderr = ''
600 let steps = 0
601 const startedAt = await $.clock.now()
602
603 yield header
604
605 working = { startedAt, step: 'Thinking' }
606 // Ten frames a second, the most the app redraws, for the working shimmer.
607 ticker?.()
608 ticker = $.clock.every(100, () => $.ui.invalidate('ui.render')).cancel
609 $.ui.invalidate('ui.render')
610
611 try {
612 for await (const piece of run) {
613 if (piece.stream === 'stderr') {
614 stderr = (stderr + piece.text).slice(-2000)
615 continue
616 }
617
618 pending += piece.text
619
620 for (let end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n')) {
621 const event = readEvent(pending.slice(0, end))
622 pending = pending.slice(end + 1)
623
624 if (event.threadId !== undefined) {
625 threadId = event.threadId
626 }
627
628 for (const file of event.files ?? []) {
629 files.add(file)
630 }
631
632 if (event.error !== undefined) {
633 error = event.error
634 }
635
636 if (event.show === undefined) {
637 continue
638 }
639
640 const line = event.isActivity
641 ? `${wasActivity ? '' : '\n'}- ${event.show}\n`
642 : `${wasActivity ? '\n' : ''}${event.show}\n\n`
643
644 wasActivity = event.isActivity === true
645 steps += event.isActivity ? 1 : 0
646 working = { startedAt, step: event.isActivity ? liveLine(event.show) : 'Writing the reply' }
647 $.ui.invalidate('ui.render')
648 answer += line
649 yield { kind: 'text', index: 0, text: line }
650 }
651 }
652 } finally {
653 ticker?.()
654 ticker = undefined
655 working = undefined
656 $.ui.invalidate('ui.render')
657 }
658
659 const ended = await run.result
660
661 if (error !== undefined || (ended.code !== 0 && answer === '')) {
662 const reason = error ?? (stderr.trim().split('\n').at(-1) || `codex exited with code ${ended.code}`)
663 const piece = `\nCodex couldn't finish: ${reason}\n`
664
665 answer += piece
666 yield { kind: 'text', index: 0, text: piece }
667 }
668
669 // Codex's JSON leaves images out; they are in its folder for this thread.
670 const made = threadId === undefined ? [] : await newImages($, threadId, startedAt)
671
672 if (made.length > 0) {
673 const piece = `\n${made.map(path => ``).join('\n')}\n`
674
675 answer += piece
676 yield { kind: 'text', index: 0, text: piece }
677 }
678
679 const took = duration((await $.clock.now()) - startedAt)
680
681 // The turn spent some of Codex's limit; show it now, not on the next one.
682 void readCodexNow($).then(read => {
683 keepCodex(read)
684 $.ui.invalidate('ui.render')
685 })
686 metas.set(keyOf(`${HEADER}\n\n${answer}`), `${steps} step${steps === 1 ? '' : 's'} · ${took}`)
687 $.ui.invalidate('ui.render')
688
689 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
690
691 const result: TurnStepResult = {
692 turnId: e.turnId,
693 index: e.index,
694 answer: `${HEADER}\n\n${answer}`,
695 toolUses: [],
696 stopReason: 'end_turn',
697 usage: null,
698 }
699
700 return result
701 })
702
703 // A limit can run out in the middle of Claude's turn: send the message again
704 // so Codex answers it, once.
705 on('turn.complete', async ($, e, next) => {
706 const asked = prompts.get(e.turnId)
707
708 prompts.delete(e.turnId)
709
710 // Work you passed to Claude by voice: its answer goes back to the voice.
711 if (asked !== undefined && claudeTasks.delete(asked) && talkSocket !== undefined && e.answer.trim() !== '') {
712 void $.http
713 .fetch('http://localhost/speak', { method: 'POST', socketPath: talkSocket, body: speakable(e.answer) })
714 .catch(() => undefined)
715 }
716
717 const done = await next(e)
718
719 if (e.agentId !== undefined) {
720 return done
721 }
722
723 const { limits, now } = await limitsNow($)
724
725 settle(limits, now)
726 $.ui.invalidate('ui.render')
727
728 const out = exhausted(limits, now)
729
730 if (e.reason !== 'error' || mode !== 'auto' || out === undefined || isRetrying || lastPrompt === undefined) {
731 isRetrying = false
732
733 return done
734 }
735
736 isRetrying = true
737 $.ui.toast(`Claude's ${kindLabel(out.kind)} limit ran out. Codex is taking your message.`)
738 void $.prompt.submit({ text: lastPrompt, asUser: true })
739
740 return done
741 })
742
743 // A Codex reply: the mark and name, its steps as rows, then its words.
744 // A Codex reply: the mark and name, its steps as rows, its words, its images.
745 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
746 if (e.surface !== 'desktop' || !isCodexReply(e.props.text)) {
747 return next(e)
748 }
749
750 const { Box, Markdown, Svg, Text } = $.ui.resolve(e)
751 const isVoice = isVoiceReply(e.props.text)
752 const reply = readReply(isVoice ? HEADER + e.props.text.trimStart().slice(VOICE_HEADER.length) : e.props.text)
753 const { words, images } = splitImages(reply.words)
754 const meta = metas.get(keyOf(e.props.text))
755
756 // A spoken reply that ended before Codex said anything leaves no row.
757 if (isVoice && words === '' && images.length === 0) {
758 return <Box />
759 }
760
761 wantPreviews($, images, previews)
762
763 return (
764 <Box flexDirection="column" gap={1}>
765 <Box flexDirection="row" alignItems="center" gap={1}>
766 {/* The Codex CLI's session header: ">_" in its accent color, then the name. */}
767 <Text color="cyan">{'>_'}</Text>
768 <Text bold>Codex</Text>
769 {isVoice && <Text dimColor>· voice</Text>}
770 {meta !== undefined && <Text dimColor>· {meta}</Text>}
771 </Box>
772 {reply.steps.length > 0 && (
773 <Box flexDirection="column" paddingLeft={3}>
774 {reply.steps.map(step => (
775 <Text dimColor={!step.isFailed} color={step.isFailed ? 'red' : undefined}>
776 {step.glyph} {step.text}
777 </Text>
778 ))}
779 </Box>
780 )}
781 {words !== '' && (
782 <Box paddingLeft={3}>{isVoice ? <Text dimColor>{words}</Text> : <Markdown text={words} />}</Box>
783 )}
784 {images.map(path => {
785 const preview = previews.get(path)
786 const card = cardFor(path, preview)
787 const name = path.split('/').at(-1) ?? path
788
789 return (
790 <Box key={`image-${path}`} flexDirection="column" paddingLeft={3} gap={0}>
791 {card !== undefined && <Svg source={card.source} alt={`Image from Codex: ${name}`} width={card.width} height={card.height} />}
792 {preview === undefined && <Text dimColor>Loading the image…</Text>}
793 <Text dimColor>
794 {preview ? `${preview.width}×${preview.height} · ` : ''}
795 {path.replace(/^\/Users\/[^/]+/, '~')}
796 </Text>
797 </Box>
798 )
799 })}
800 </Box>
801 )
802 })
803
804 // What you said to Codex's voice, kept as a quiet row rather than a bubble.
805 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
806 const to = voiceRows.get(e.props.text.trim())
807
808 if (e.surface !== 'desktop' || to === undefined) {
809 return next(e)
810 }
811
812 const { Box, Text } = $.ui.resolve(e)
813
814 // A reply Codex spoke on its own shows only as Codex's row.
815 if (to === 'hidden') {
816 return <Box />
817 }
818
819 return (
820 <Box flexDirection="row" gap={1}>
821 <Text dimColor>{'\u{1F399}\uFE0E'}</Text>
822 <Text bold dimColor>
823 {to === 'claude' ? 'You → Claude' : 'You'}
824 </Text>
825 <Text dimColor>{e.props.text}</Text>
826 </Box>
827 )
828 })
829
830 // Claude's `codex` tool: the reply as Codex's card, its images drawn.
831 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
832 const images = e.props.tool === 'mcp__nightshift__codex' ? (toolImages.get(e.props.tool_use_id) ?? []) : []
833
834 if (e.surface !== 'desktop' || e.props.isErrored || images.length === 0) {
835 return next(e)
836 }
837
838 const { Box, Svg, Text } = $.ui.resolve(e)
839
840 wantPreviews($, images, previews)
841
842 return (
843 <Box flexDirection="column" gap={1}>
844 {images.map(path => {
845 const preview = previews.get(path)
846 const card = cardFor(path, preview)
847
848 return (
849 <Box key={`image-${path}`} flexDirection="column">
850 {card !== undefined && <Svg source={card.source} alt={`Image from Codex: ${path.split('/').at(-1) ?? path}`} width={card.width} height={card.height} />}
851 {preview === undefined && <Text dimColor>Loading the image…</Text>}
852 <Text dimColor>
853 {preview ? `${preview.width}×${preview.height} · ` : ''}
854 {path.replace(/^\/Users\/[^/]+/, '~')}
855 </Text>
856 </Box>
857 )
858 })}
859 </Box>
860 )
861 })
862
863 // Tools set to Codex: Claude's browser and Mac-app tools say they are off and
864 // wait behind ToolSearch, and the codex tool says it takes that work.
865 on('tool.describe', async ($, e, next) => {
866 if (toolsWorker !== 'codex') {
867 return next(e)
868 }
869
870 if (HANDED_TOOLS.test(e.tool)) {
871 return { description: HANDED_TOOL_NOTE, isDeferred: true }
872 }
873
874 if (e.tool === 'mcp__nightshift__codex') {
875 const described = await next(e)
876
877 return { ...described, description: `${described.description}${TOOLS_TO_CODEX}`, isDeferred: false }
878 }
879
880 return next(e)
881 })
882
883 // And a call that still reaches one of them is turned back to Codex.
884 on('tool.call', async ($, e, next) => {
885 if (toolsWorker === 'codex' && HANDED_TOOLS.test(e.tool)) {
886 return { deny: HANDED_TOOL_NOTE }
887 }
888
889 return next(e)
890 })
891
892 // One Codex run's say on its browser steps. Asking: each step that changes
893 // something waits on Allow or Deny above the strip; looking runs at once;
894 // "Allow all" lets the rest of this run through. Auto: nothing waits.
895 const browserDecider = (redraw: () => void) => {
896 let isTrusted = false
897 const mine = new Set<(allowed: boolean) => void>()
898
899 return {
900 decide: (call: BrowserCall): Promise<boolean> => {
901 if (browserAccess === 'auto' || isTrusted || isLookOnly(call)) {
902 return Promise.resolve(true)
903 }
904
905 return new Promise<boolean>(resolve => {
906 const answer = (allowed: boolean) => {
907 mine.delete(answer)
908 asks.splice(
909 asks.findIndex(ask => ask.answer === answer),
910 1,
911 )
912 redraw()
913 resolve(allowed)
914 }
915
916 mine.add(answer)
917 asks.push({
918 call,
919 answer,
920 allowRest: () => {
921 isTrusted = true
922 answer(true)
923 },
924 })
925 redraw()
926 })
927 },
928 // The run is over: anything still waiting is declined and leaves the strip.
929 drop: () => {
930 for (const answer of [...mine]) {
931 answer(false)
932 }
933 },
934 }
935 }
936
937 on('tool.call', { tool: 'mcp__nightshift__codex' }, async ($, e) => {
938 // The tool's arguments ride on the event itself, beside `tool` and its id.
939 const input = e as unknown as { prompt?: unknown; images?: unknown }
940 const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : ''
941 const images = Array.isArray(input.images) ? input.images.filter((path): path is string => typeof path === 'string') : []
942
943 if (prompt === '') {
944 return { deny: 'Give Codex a prompt.' }
945 }
946
947 // Codex browses in Claude's browser pane, through the bridge, for this run.
948 const socket = `${await privateDir($)}nightshift-browser-${(await $.clock.now()).toString(36)}.sock`
949 const decider = browserDecider(() => $.ui.invalidate('ui.render'))
950 let isRunning = true
951 const pump = pumpBrowser(
952 $,
953 socket,
954 decider.decide,
955 () => isRunning,
956 paneWatcher(
957 text => $.ui.toast(text, { timeoutMs: 8000 }),
958 () => $.ui.invalidate('ui.render'),
959 ),
960 )
961 let ran: Awaited<ReturnType<typeof runCodexTool>>
962
963 try {
964 ran = await runCodexTool(
965 $,
966 { prompt: `${prompt}${BROWSER_NOTE}`, images },
967 { model: codexModel, effort: effortWithin(codexEffort, offered()), config: browserConfig($.plugin.root, socket) },
968 )
969 } finally {
970 isRunning = false
971 decider.drop()
972 }
973
974 void pump
975
976 for (const [at, path] of ran.images.entries()) {
977 const preview = ran.previews[at]
978
979 if (preview !== undefined) {
980 previews.set(path, preview)
981 }
982 }
983
984 toolImages.set(e.tool_use_id, ran.images)
985 void readCodexNow($).then(read => {
986 keepCodex(read)
987 $.ui.invalidate('ui.render')
988 })
989
990 // Blocks the model reads as they are, so each image is an API image block
991 // (base64 source); the MCP shape ({ data, mimeType }) reaches the API empty.
992 return {
993 result: [
994 { type: 'text', text: ran.text },
995 ...ran.previews.map(preview => ({
996 type: 'image',
997 source: { type: 'base64', media_type: 'image/jpeg', data: preview.data },
998 })),
999 ],
1000 }
1001 })
1002
1003 // While Codex works, the spinner says what it is doing; the strip animates.
1004 on('ui.render', { component: 'Spinner' }, ($, e, next) =>
1005 working === undefined ? next(e) : next({ ...e, props: { ...e.props, message: `Codex · ${working.step}` } }),
1006 )
1007
1008 // The Codex strip above the box: the mark, its 7-day meter, and while it
1009 // answers, the shimmer, the ripple and a way back to Claude.
1010 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1011 // The strip holds controls, so it shows even before Codex's limit is known.
1012 if (e.props.hasSurvey) {
1013 return next(e)
1014 }
1015
1016 const now = await $.clock.now()
1017 // Read at each draw, so a week that reset while idle starts again at zero.
1018 const { week, weekResetsAt } = currentWeek(codexLimits, now)
1019 const isWorking = working !== undefined
1020 const title = isWorking ? (working?.step ?? 'Thinking') : 'Codex'
1021 // While Codex covers for a spent Claude limit, say when Claude is back;
1022 // once that time has passed, the next message goes to Claude.
1023 const isClaudeOut = lastOut !== undefined && (lastOut.resetsAt === undefined || Date.parse(lastOut.resetsAt) > now)
1024 const note = mode === 'auto' && isClaudeOut ? `Claude back ${resetLabel(lastOut?.resetsAt)}` : undefined
1025 const weekReset = weekResetsAt === undefined ? undefined : resetLabel(new Date(weekResetsAt).toISOString())
1026 if (!isStripOpen && !isWorking && talk.phase === 'off' && asks.length === 0 && mode !== 'codex' && note === undefined) {
1027 return next(e)
1028 }
1029
1030 const closeStrip = () => {
1031 isStripOpen = false
1032 $.ui.invalidate('ui.render')
1033 }
1034 const back = () => {
1035 mode = 'auto'
1036 isCodexOn = lastOut !== undefined
1037 $.ui.invalidate('ui.render')
1038 }
1039
1040 const pickEffort = async (effort: CodexEffort) => {
1041 codexEffort = effort
1042 $.ui.invalidate('ui.render')
1043 await $.store.set('codexEffort', effort)
1044 }
1045 const pickModel = async (model: string) => {
1046 codexModel = model
1047 $.ui.invalidate('ui.render')
1048 await $.store.set('codexModel', model)
1049 }
1050 const choices = models.map(model => ({ value: model.slug, label: model.name }))
1051 const levels = offered()
1052 const current = effortWithin(codexEffort, levels)
1053
1054 // Talk: Codex's live voice. The bridge runs while the loop reads it; each
1055 // finished exchange is kept in the chat as your words and Codex's reply.
1056 const startTalk = async () => {
1057 if (talk.phase !== 'off') {
1058 return
1059 }
1060
1061 const [cwd, at, dir] = await Promise.all([$.session.cwd(), $.clock.now(), privateDir($)])
1062 const socket = `${dir}nightshift-voice-${at.toString(36)}.sock`
1063 const browserSocket = `${dir}nightshift-browser-${at.toString(36)}v.sock`
1064 const run = $.process.spawn({
1065 argv: voiceArgv($.plugin.root, cwd, socket, voiceWorker, browserSocket, {
1066 model: codexModel,
1067 effort: effortWithin(codexEffort, offered()),
1068 }),
1069 cwd,
1070 input: '',
1071 })
1072 // What Codex browses while you talk shows in Claude's browser pane too.
1073 const decider = browserDecider(() => $.ui.invalidate('ui.render'))
1074 let isTalking = true
1075 const pump = pumpBrowser(
1076 $,
1077 browserSocket,
1078 decider.decide,
1079 () => isTalking,
1080 paneWatcher(
1081 text => $.ui.toast(text, { timeoutMs: 8000 }),
1082 () => $.ui.invalidate('ui.render'),
1083 ),
1084 )
1085 let pending = ''
1086 let failure: string | undefined
1087 let drawn = ''
1088
1089 // The reply Codex is speaking, streaming into its chat row. It ends a
1090 // moment after Codex stops, when you speak again, or when the call ends.
1091 let reply: VoiceFeed | undefined
1092 let heard = 0
1093 const endReply = () => {
1094 reply?.close()
1095 reply = undefined
1096 }
1097 const openReply = (text: string, row: Worker | 'hidden'): VoiceFeed => {
1098 endReply()
1099
1100 const fresh = new VoiceFeed()
1101
1102 feeds.set(text, [...(feeds.get(text) ?? []), fresh])
1103 voiceRows.set(text, row)
1104 void $.prompt.submit({ text, asUser: true })
1105 reply = fresh
1106
1107 return fresh
1108 }
1109
1110 talk = { ...QUIET, phase: 'connecting' }
1111 talkSocket = socket
1112 talkRun = run
1113 $.ui.invalidate('ui.render')
1114
1115 try {
1116 for await (const piece of run) {
1117 if (piece.stream === 'stderr') {
1118 continue
1119 }
1120
1121 pending += piece.text
1122
1123 for (let end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n')) {
1124 const event = readBridge(pending.slice(0, end))
1125
1126 pending = pending.slice(end + 1)
1127
1128 if (event === undefined) {
1129 continue
1130 }
1131
1132 talk = hear(talk, event)
1133
1134 if (event.t === 'state' && event.error !== undefined) {
1135 failure = event.error
1136 }
1137
1138 // Your words land as a row the moment you finish; speaking again
1139 // closes the reply before it.
1140 if (event.t === 'caption' && event.role === 'user') {
1141 if (event.final) {
1142 openReply(event.text, 'codex')
1143 } else if (reply?.hasWords()) {
1144 endReply()
1145 }
1146 }
1147
1148 // Codex's words stream into the open reply, or open one of their own.
1149 if (event.t === 'caption' && event.role === 'assistant') {
1150 const into = reply ?? openReply(SPOKE, 'hidden')
1151 const mark = ++heard
1152
1153 into.assistant(event.text, event.final)
1154
1155 if (event.final) {
1156 $.clock.after(1500, () => {
1157 if (heard === mark && reply === into) {
1158 endReply()
1159 }
1160 })
1161 }
1162 }
1163
1164 if (event.t === 'image') {
1165 ;(reply ?? openReply(SPOKE, 'hidden')).image(event.path)
1166 }
1167
1168 // Work you want Claude to do: Codex's reply closes, then Claude takes it.
1169 if (event.t === 'claude' && event.task !== '') {
1170 endReply()
1171 claudeTasks.add(event.task)
1172 voiceRows.set(event.task, 'claude')
1173 void $.prompt.submit({ text: event.task, asUser: true })
1174 }
1175
1176 if (event.t === 'state' && event.phase === 'ended') {
1177 endReply()
1178 }
1179
1180 // A redraw re-runs every Nightshift row in the chat, so the strip redraws
1181 // only when what it shows changes: not for each word of the call.
1182 const shown = `${talk.phase}|${talk.muted}|${talkState(talk)}`
1183
1184 if (shown !== drawn) {
1185 drawn = shown
1186 $.ui.invalidate('ui.render')
1187 }
1188 }
1189 }
1190 } catch (error) {
1191 failure = error instanceof Error ? error.message : String(error)
1192 }
1193
1194 endReply()
1195 talk = QUIET
1196 talkSocket = undefined
1197 talkRun = undefined
1198 isTalking = false
1199 decider.drop()
1200 void pumphooks/codex.ts 389 lines1// Pure helpers: reading Claude's limits, briefing Codex, and turning
2// `codex exec --json` events into lines of the reply.
3
4export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5export type Brief = { role: 'user' | 'assistant'; text: string; tools: string[] }
6
7export type CodexEvent = {
8 threadId?: string
9 show?: string
10 isActivity?: boolean
11 files?: string[]
12 error?: string
13}
14
15const KIND_LABELS: Record<string, string> = {
16 five_hour: '5-hour',
17 seven_day: 'weekly',
18 spend_limit: 'spend',
19}
20
21const MESSAGE_CAP = 4000
22const CONTEXT_CAP = 40000
23const FIRST_CONTEXT = 20
24
25export function kindLabel(kind: string): string {
26 return KIND_LABELS[kind] ?? kind.replaceAll('_', ' ')
27}
28
29/** The window that has run out and has not reset yet, if any. */
30export function exhausted(limits: readonly Limit[], now: number): Limit | undefined {
31 return limits.find(
32 limit => limit.percentUsed >= 100 && (limit.resetsAt === undefined || Date.parse(limit.resetsAt) > now),
33 )
34}
35
36/** The fullest window, for the status line. */
37export function fullest(limits: readonly Limit[]): Limit | undefined {
38 return [...limits].sort((a, b) => b.percentUsed - a.percentUsed)[0]
39}
40
41export function resetLabel(resetsAt: string | undefined): string {
42 if (resetsAt === undefined) {
43 return 'when it resets'
44 }
45
46 const at = new Date(resetsAt)
47
48 if (Number.isNaN(at.getTime())) {
49 return 'when it resets'
50 }
51
52 const day = at.toLocaleDateString('en-US', { weekday: 'short' })
53 const time = at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
54
55 return `${day} ${time}`
56}
57
58function clip(text: string, cap: number): string {
59 return text.length <= cap ? text : `${text.slice(0, cap)} […]`
60}
61
62function transcript(briefs: readonly Brief[]): string {
63 const lines: string[] = []
64 let size = 0
65
66 // Newest first, so the cap drops the oldest messages.
67 for (const brief of [...briefs].reverse()) {
68 const who = brief.role === 'user' ? 'User' : 'Assistant'
69 const tools = brief.tools.length > 0 ? ` (used: ${[...new Set(brief.tools)].join(', ')})` : ''
70 const line = `[${who}]${tools}\n${clip(brief.text.trim() || '(no text)', MESSAGE_CAP)}`
71
72 if (size + line.length > CONTEXT_CAP) {
73 break
74 }
75
76 lines.unshift(line)
77 size += line.length
78 }
79
80 return lines.join('\n\n')
81}
82
83/** What Codex reads: the situation once, then only what it has not seen. */
84export function buildPrompt(args: {
85 isFirst: boolean
86 cwd: string
87 why: string
88 context: readonly Brief[]
89 text: string
90}): string {
91 const parts: string[] = []
92
93 if (args.isFirst) {
94 parts.push(
95 `You are taking over a coding conversation from Claude Code in ${args.cwd} because ${args.why}. ` +
96 'Continue the work in the same direction. You can read and edit files in this directory. ' +
97 'Reply to the user directly and briefly; they read your reply in the same chat.',
98 )
99
100 const recent = transcript(args.context.slice(-FIRST_CONTEXT))
101
102 if (recent !== '') {
103 parts.push(`The conversation so far, oldest first:\n\n${recent}`)
104 }
105 } else if (args.context.length > 0) {
106 parts.push(`Since your last reply, the conversation went on:\n\n${transcript(args.context)}`)
107 }
108
109 parts.push(`The user's new message:\n\n${args.text.trim() || '(continue)'}`)
110
111 return parts.join('\n\n---\n\n')
112}
113
114function unwrapShell(command: string): string {
115 const found = /^\/bin\/(?:ba|z)?sh -lc '([\s\S]*)'$/.exec(command)
116
117 return (found?.[1] ?? command).replaceAll("'\\''", "'")
118}
119
120function inlineCode(text: string): string {
121 const flat = text.replace(/\s+/g, ' ').trim()
122 const short = flat.length > 120 ? `${flat.slice(0, 117)}…` : flat
123
124 return `\`${short.replaceAll('`', "'")}\``
125}
126
127type RawItem = {
128 type?: string
129 text?: string
130 command?: string
131 exit_code?: number | null
132 changes?: { path?: string; kind?: string }[]
133 query?: string
134 server?: string
135 tool?: string
136 message?: string
137}
138
139type RawEvent = {
140 type?: string
141 thread_id?: string
142 item?: RawItem
143 error?: { message?: string }
144 message?: string
145}
146
147/** One line of `codex exec --json` output; a line that is not JSON is skipped. */
148export function readEvent(line: string): CodexEvent {
149 let event: RawEvent
150
151 try {
152 event = JSON.parse(line) as RawEvent
153 } catch {
154 return {}
155 }
156
157 if (event.type === 'thread.started' && typeof event.thread_id === 'string') {
158 return { threadId: event.thread_id }
159 }
160
161 if (event.type === 'turn.failed') {
162 return { error: event.error?.message ?? 'Codex stopped with an error' }
163 }
164
165 if (event.type === 'error') {
166 return { error: event.message ?? 'Codex reported an error' }
167 }
168
169 if (event.type !== 'item.completed' || event.item === undefined) {
170 return {}
171 }
172
173 const item = event.item
174
175 switch (item.type) {
176 case 'agent_message':
177 return item.text ? { show: item.text } : {}
178 case 'command_execution': {
179 const failed = typeof item.exit_code === 'number' && item.exit_code !== 0
180 const ran = `ran ${inlineCode(unwrapShell(item.command ?? ''))}${failed ? ` (exit ${item.exit_code})` : ''}`
181
182 return { show: ran, isActivity: true }
183 }
184 case 'file_change': {
185 const files = (item.changes ?? []).map(change => change.path ?? '').filter(path => path !== '')
186 const verb = (kind: string | undefined) => (kind === 'add' ? 'added' : kind === 'delete' ? 'deleted' : 'edited')
187 const lines = (item.changes ?? [])
188 .filter(change => change.path)
189 .map(change => `${verb(change.kind)} ${inlineCode(change.path ?? '')}`)
190
191 return lines.length > 0 ? { show: lines.join('\n- '), isActivity: true, files } : {}
192 }
193 case 'web_search':
194 return item.query ? { show: `searched the web for ${inlineCode(item.query)}`, isActivity: true } : {}
195 case 'mcp_tool_call':
196 return { show: `used ${inlineCode(`${item.server ?? 'mcp'}.${item.tool ?? 'tool'}`)}`, isActivity: true }
197 case 'error':
198 return { error: item.message ?? 'Codex reported an error' }
199 default:
200 return {}
201 }
202}
203
204/** The command Nightshift runs: through a login shell, so `codex` and `node` resolve as in a terminal. */
205export function codexArgv(
206 threadId: string | undefined,
207 prompt: string,
208 choice: { model?: string; effort?: string; images?: readonly string[]; config?: readonly string[] } = {},
209): string[] {
210 const codex = threadId === undefined ? ['exec'] : ['exec', 'resume', threadId]
211 const model = choice.model === undefined ? [] : ['-m', choice.model]
212 const effort = choice.effort === undefined ? [] : ['-c', `model_reasoning_effort="${choice.effort}"`]
213
214 return [
215 '/bin/zsh',
216 '-lc',
217 'exec codex "$@"',
218 'codex',
219 ...codex,
220 '--json',
221 '--skip-git-repo-check',
222 '-c',
223 'sandbox_mode="workspace-write"',
224 ...model,
225 ...effort,
226 ...(choice.config ?? []).flatMap(pair => ['-c', pair]),
227 // `--image=` so the prompt after it is never read as one more image.
228 ...(choice.images ?? []).map(path => `--image=${path}`),
229 prompt,
230 ]
231}
232
233/**
234 * Claude's browser pane offered to a Codex run: the bridge as an MCP server.
235 * Codex does not ask before its tools because Nightshift asks in Claude instead
236 * (or not, when the user picked Tools: Codex, auto).
237 */
238export function browserConfig(root: string, socket: string): string[] {
239 return [
240 'mcp_servers.claude_browser.command="node"',
241 `mcp_servers.claude_browser.args=${JSON.stringify([`${root}/hooks/browser-mcp.mjs`, socket])}`,
242 'mcp_servers.claude_browser.tool_timeout_sec=180',
243 'mcp_servers.claude_browser.default_tools_approval_mode="approve"',
244 ]
245}
246
247export const BROWSER_NOTE =
248 '\n\nFor any web browsing, use the claude_browser tools: they drive the browser pane the user is watching in Claude. ' +
249 "Use @chrome only if this brief asks for the user's own Chrome."
250
251export type CodexLimits = { fiveHour?: number; week?: number; weekResetsAt?: number }
252
253const WEEK_MS = 7 * 24 * 60 * 60 * 1000
254
255type RawWindow = { used_percent?: number; window_minutes?: number; resets_at?: number | null } | null | undefined
256
257// Codex has logged the windows a minute either side of their nominal length.
258const isWindow = (minutes: number | undefined, nominal: number) =>
259 minutes !== undefined && Math.abs(minutes - nominal) <= 60
260
261/** Codex's own windows, from the newest `rate_limits` line of its session logs. */
262export function readCodexLimits(line: string, now: number): CodexLimits | undefined {
263 let found: { payload?: { rate_limits?: { primary?: RawWindow; secondary?: RawWindow } } }
264
265 try {
266 found = JSON.parse(line) as typeof found
267 } catch {
268 return undefined
269 }
270
271 const limits = found.payload?.rate_limits
272
273 if (!limits) {
274 return undefined
275 }
276
277 const read: CodexLimits = {}
278
279 for (const window of [limits.primary, limits.secondary]) {
280 if (!window || typeof window.used_percent !== 'number') {
281 continue
282 }
283
284 const resetsAt = typeof window.resets_at === 'number' ? window.resets_at * 1000 : undefined
285 // A window that has reset since the line was written starts at zero.
286 const used = resetsAt !== undefined && resetsAt <= now ? 0 : window.used_percent
287
288 if (isWindow(window.window_minutes, 300)) {
289 read.fiveHour = used
290 } else if (isWindow(window.window_minutes, 10080)) {
291 const week = currentWeek({ week: window.used_percent, weekResetsAt: resetsAt }, now)
292
293 read.week = week.week
294
295 if (week.weekResetsAt !== undefined) {
296 read.weekResetsAt = week.weekResetsAt
297 }
298 }
299 }
300
301 return read
302}
303
304/**
305 * Codex's 7-day window as it stands at `now`: once the read window has reset,
306 * the next one has begun at zero and resets a week on. The strip asks at each
307 * draw, so an idle session never shows a reset time that has passed.
308 */
309export function currentWeek(limits: CodexLimits | undefined, now: number): Pick<CodexLimits, 'week' | 'weekResetsAt'> {
310 let resetsAt = limits?.weekResetsAt
311
312 if (limits?.week === undefined || resetsAt === undefined || resetsAt > now) {
313 return { week: limits?.week, weekResetsAt: resetsAt }
314 }
315
316 while (resetsAt <= now) {
317 resetsAt += WEEK_MS
318 }
319
320 return { week: 0, weekResetsAt: resetsAt }
321}
322
323/** How far into its 7-day window Codex's limit is, 0 to 1, from when it resets. */
324export function weekElapsed(resetsAt: number | undefined, now: number): number | undefined {
325 if (resetsAt === undefined) {
326 return undefined
327 }
328
329 return Math.min(1, Math.max(0, 1 - (resetsAt - now) / WEEK_MS))
330}
331
332/** `5h 3% · 7d 15%`: short enough for the line under the box. */
333export function windows(fiveHour: number | undefined, week: number | undefined): string {
334 const parts = [
335 fiveHour === undefined ? undefined : `5h ${Math.round(fiveHour)}%`,
336 week === undefined ? undefined : `7d ${Math.round(week)}%`,
337 ].filter(part => part !== undefined)
338
339 return parts.join(' · ')
340}
341
342/** The script Nightshift runs to find that line, newest logs first. */
343// The newest log that has a reading, and its last one: newest file first,
344// since grep would print an older file's lines after a newer one's.
345export const CODEX_LIMITS_SCRIPT =
346 'setopt nullglob; cd ~/.codex/sessions 2>/dev/null || exit 0; ' +
347 'for file in $(ls -t */*/*/*.jsonl 2>/dev/null | head -8); do ' +
348 'line=$(grep -h \'"rate_limits":{\' $file 2>/dev/null | tail -1); ' +
349 '[[ -n $line ]] && { print -r -- $line; exit 0 }; done'
350
351export type CodexModel = { slug: string; name: string; efforts?: string[] }
352
353/** The models Codex lists, from its own cache (`~/.codex/models_cache.json`). */
354export function readModels(cache: string): CodexModel[] {
355 try {
356 const parsed = JSON.parse(cache) as {
357 models?: {
358 slug?: string
359 display_name?: string
360 visibility?: string
361 supported_reasoning_levels?: { effort?: string }[]
362 }[]
363 }
364
365 return (parsed.models ?? [])
366 .filter(model => model.visibility === 'list' && typeof model.slug === 'string')
367 .map(model => ({
368 slug: model.slug ?? '',
369 name: model.display_name ?? model.slug ?? '',
370 efforts: model.supported_reasoning_levels?.map(level => level.effort ?? '').filter(effort => effort !== ''),
371 }))
372 } catch {
373 return []
374 }
375}
376
377/** `model = "..."` and `model_reasoning_effort = "..."` from `~/.codex/config.toml`. */
378export function readCodexDefaults(toml: string): { model?: string; effort?: string } {
379 const value = (key: string) => new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm').exec(toml)?.[1]
380
381 return { model: value('model'), effort: value('model_reasoning_effort') }
382}
383
384// Only the two top-level settings Nightshift uses leave config.toml: the file can
385// hold other tools' secrets further down.
386export const CODEX_SETUP_SCRIPT =
387 'cat ~/.codex/models_cache.json 2>/dev/null; printf "\\n@@config@@\\n"; ' +
388 "awk '/^\\[/{exit} /^(model|model_reasoning_effort)[ \\t]*=/' ~/.codex/config.toml 2>/dev/null"
389hooks/look.ts 325 lines1// What Nightshift draws with: the Codex CLI's logo (codex-frames.ts) as a still dot
2// matrix, its working-line shimmer, and a Codex reply read back into its
3// header, its steps and its words.
4
5import { FRAMES } from './codex-frames'
6
7export const HEADER = '**Codex**'
8
9const INK = '#ecebe6'
10// The strip's own background, which dim text fades toward.
11const STRIP = '#262624'
12
13// Braille dot order: bit n of a cell is this (column, row) within its 2×4 dots.
14const DOT_AT: readonly (readonly [number, number])[] = [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [0, 3], [1, 3]]
15
16/**
17 * The Codex CLI's Braille logo at rest as a dot matrix: every dot its renderer
18 * lit, in its cell's color, `height` pixels tall. It never moves.
19 */
20export function logoDots(height: number): { source: string; width: number } {
21 const rows = FRAMES[0] ?? []
22 const columns = [...(rows[0]?.[0] ?? '')].length
23 const step = height / (rows.length * 4)
24 const width = Math.round(step * columns * 2)
25 const radius = (step * 0.42).toFixed(2)
26 const dots: string[] = []
27
28 rows.forEach(([text, colors], row) => {
29 const palette = colors.split(',')
30
31 ;[...text].forEach((char, column) => {
32 const bits = (char.codePointAt(0) ?? 0x2800) - 0x2800
33
34 DOT_AT.forEach(([dx, dy], bit) => {
35 if (bits & (1 << bit)) {
36 const x = ((column * 2 + dx + 0.5) * step).toFixed(2)
37 const y = ((row * 4 + dy + 0.5) * step).toFixed(2)
38
39 dots.push(`<circle cx="${x}" cy="${y}" r="${radius}" fill="${palette[column] ?? INK}"/>`)
40 }
41 })
42 })
43 })
44
45 return {
46 source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${dots.join('')}</svg>`,
47 width,
48 }
49}
50
51function mix(a: string, b: string, share: number): string {
52 const channel = (hex: string, at: number) => parseInt(hex.slice(1 + at * 2, 3 + at * 2), 16)
53
54 return `#${[0, 1, 2].map(at => Math.round(channel(a, at) * share + channel(b, at) * (1 - share)).toString(16).padStart(2, '0')).join('')}`
55}
56
57/**
58 * Each character's color in the working line, as the Codex CLI's
59 * summary_shimmer.rs computes it: a band at least 3 characters wide crosses
60 * the words in 1s every 4s after 0.6s, from half ink to full ink.
61 */
62export function shimmerColors(text: string, elapsed: number): string[] {
63 const chars = [...text]
64 const half = Math.max(chars.length * 0.1, 3)
65 const sweep = Math.min(Math.max(0, elapsed - 600) % 4000, 1000) / 1000
66 const position = sweep * (chars.length + 2 * half) - half
67
68 return chars.map((_, at) => {
69 const distance = Math.min(Math.abs(at + 0.5 - position) / half, 1)
70 const intensity = 0.5 * (1 + Math.cos(Math.PI * distance))
71
72 return mix(INK, STRIP, 0.5 + 0.5 * intensity)
73 })
74}
75
76/** The working bullet's color: it brightens as its own 2s shimmer passes, as motion.rs draws it. */
77export function bulletColor(elapsed: number): string {
78 const position = ((elapsed % 2000) / 2000) * 21
79 const distance = Math.abs(10 - position)
80 const intensity = distance <= 5 ? 0.5 * (1 + Math.cos((Math.PI * distance) / 5)) : 0
81
82 return mix(INK, STRIP, 0.5 + 0.5 * intensity)
83}
84
85export type Step = { glyph: string; text: string; isFailed: boolean }
86export type Reply = { steps: Step[]; words: string }
87
88const STEP = /^- (ran|edited|added|deleted|searched the web for|used) (.+)$/
89
90const GLYPHS: Record<string, string> = {
91 ran: '$',
92 edited: '✎',
93 added: '+',
94 deleted: '−',
95 'searched the web for': '⌕',
96 used: '⚙',
97}
98
99const LABELS: Record<string, string> = {
100 ran: 'Ran',
101 edited: 'Edited',
102 added: 'Added',
103 deleted: 'Deleted',
104 'searched the web for': 'Searched',
105 used: 'Used',
106}
107
108export function isCodexReply(text: string): boolean {
109 return text.trimStart().startsWith(HEADER)
110}
111
112/** Splits a Codex reply into its step rows and the rest, kept as markdown. */
113export function readReply(text: string): Reply {
114 const body = text.trimStart().slice(HEADER.length)
115 const steps: Step[] = []
116 const words: string[] = []
117
118 for (const line of body.split('\n')) {
119 const found = STEP.exec(line.trim())
120
121 if (found) {
122 const verb = found[1] ?? 'ran'
123 const rest = (found[2] ?? '').replaceAll('`', '')
124
125 steps.push({
126 glyph: GLYPHS[verb] ?? '·',
127 text: `${LABELS[verb] ?? verb} ${rest.replace(/ \(exit \d+\)$/, '')}`,
128 isFailed: / \(exit \d+\)$/.test(rest),
129 })
130 } else {
131 words.push(line)
132 }
133 }
134
135 return { steps, words: words.join('\n').trim() }
136}
137
138/** `Running npm test`: the live line while Codex works, from one step. */
139export function liveLine(step: string): string {
140 const found = STEP.exec(`- ${step}`)
141
142 if (!found) {
143 return 'Thinking'
144 }
145
146 const rest = (found[2] ?? '').replaceAll('`', '').replace(/ \(exit \d+\)$/, '')
147 const verbs: Record<string, string> = {
148 ran: 'Running',
149 edited: 'Editing',
150 added: 'Adding',
151 deleted: 'Deleting',
152 'searched the web for': 'Searching',
153 used: 'Using',
154 }
155
156 return `${verbs[found[1] ?? ''] ?? 'Working on'} ${rest}`
157}
158
159export function duration(ms: number): string {
160 const seconds = Math.max(0, Math.round(ms / 1000))
161
162 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
163}
164
165// Codex's reasoning levels as the strip offers them, lightest first.
166export const CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
167export type CodexEffort = (typeof CODEX_EFFORTS)[number]
168
169export const EFFORT_LABELS: Record<CodexEffort, string> = {
170 low: 'Light',
171 medium: 'Medium',
172 high: 'High',
173 xhigh: 'XHigh',
174 max: 'Max',
175}
176
177/**
178 * The levels a model takes, from what its cache entry lists; every level when
179 * the cache says nothing.
180 */
181export function effortsFor(supported: readonly string[] | undefined): CodexEffort[] {
182 const known = CODEX_EFFORTS.filter(effort => supported === undefined || supported.includes(effort))
183
184 return known.length > 0 ? known : [...CODEX_EFFORTS]
185}
186
187/** The chosen level, or the closest one below it that the model takes. */
188export function effortWithin(chosen: CodexEffort, offered: readonly CodexEffort[]): CodexEffort {
189 const at = CODEX_EFFORTS.indexOf(chosen)
190 const below = offered.filter(effort => CODEX_EFFORTS.indexOf(effort) <= at)
191
192 return below.at(-1) ?? offered[0] ?? chosen
193}
194
195// The Codex 7-day limit as a week of square tiles, read like a battery: the
196// share left lit in the logo's ink, full at 100% and empty at 0%, and a faint
197// tick where the week's remaining time ends, so lit tiles past the tick mean
198// more left than the week needs. One static SVG, 26px tall.
199const LIMIT_FONT = '-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif'
200const LIMIT_TEXT = '#ecebe6'
201const LIMIT_DIM = '#8a8984'
202const LIMIT_LIT = '#d4d4ce'
203const LIMIT_UNLIT = '#3a3936'
204const LIMIT_TICK = '#74736d'
205// From 75% the lit tiles warm to honey; from 90% they turn a soft red.
206const LIMIT_WARM = '#d9b779'
207const LIMIT_HOT = '#e36d5d'
208
209// Measured with the strip's font: "100%" at 12.5px semibold is 37px wide,
210// "Wed 22:59" at 11px about 58px.
211const PERCENT_RESERVE = 38
212const RESET_RESERVE = 58
213const GAP_TILES_TEXT = 8
214const GAP_TEXT_RESET = 10
215// Room left of the first tile for the "now" tick at the start of the week.
216const TICK_ROOM = 2
217// Narrower than this, the reset time gives way to the tiles.
218const MIN_RESET_WIDTH = 280
219
220type TileGrid = { tile: number; gap: number; groupGap: number; perDay: number; day: number; span: number; count: number }
221
222const clampTo = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
223
224/**
225 * The week's grid that best fills `space`: 7 day groups of whole-pixel square
226 * tiles, the most per day that fit, then the larger tile; the gap between
227 * days clearly wider than inside one. Left-over room sits on the left.
228 */
229function tileGrid(space: number): TileGrid {
230 let best: TileGrid | undefined
231
232 for (const tile of [8, 7, 6]) {
233 if (tile === 6 && best !== undefined) {
234 break
235 }
236
237 const gap = 3
238
239 for (let perDay = 2; perDay <= 5; perDay++) {
240 const day = perDay * tile + (perDay - 1) * gap
241 const groupGap = Math.min(12, Math.floor((space - 7 * day) / 6))
242
243 if (groupGap < 7) {
244 continue
245 }
246
247 if (best === undefined || perDay > best.perDay) {
248 best = { tile, gap, groupGap, perDay, day, span: 7 * day + 6 * groupGap, count: 7 * perDay }
249 }
250 }
251
252 if (best !== undefined && tile === 8 && best.perDay >= 3) {
253 break
254 }
255 }
256
257 return best ?? { tile: 6, gap: 3, groupGap: 7, perDay: 1, day: 6, span: 7 * 6 + 6 * 7, count: 7 }
258}
259
260// Wide enough for 8px tiles, three a day, beside "100%" and the reset time.
261export const LIMIT_WIDTH = 368
262
263/** The limit meter's SVG: `percent` used (drawn as what is left), `elapsed` of the week gone (0–1), and the reset label. */
264export function limitTiles(args: { percent: number; elapsed?: number; reset?: string; width?: number }): {
265 source: string
266 width: number
267} {
268 const width = args.width ?? LIMIT_WIDTH
269 const height = 26
270 const used = clampTo(Number.isFinite(args.percent) ? args.percent : 0, 0, 100)
271 const showReset = args.reset !== undefined && args.reset !== '' && width >= MIN_RESET_WIDTH
272 const tilesRight = width - PERCENT_RESERVE - GAP_TILES_TEXT - (showReset ? RESET_RESERVE + GAP_TEXT_RESET : 0)
273 const grid = tileGrid(tilesRight - TICK_ROOM)
274 const left = tilesRight - grid.span
275 const baseline = 17.5
276 // Tiles stand on the text's baseline and reach about its cap height.
277 const top = 9 + (8 - grid.tile)
278
279 const remaining = 100 - used
280 // Anything left lights one tile; only an untouched limit lights them all.
281 const lit =
282 remaining <= 0 ? 0 : remaining >= 100 ? grid.count : clampTo(Math.round((remaining / 100) * grid.count), 1, grid.count - 1)
283 const ink = used >= 90 ? LIMIT_HOT : used >= 75 ? LIMIT_WARM : LIMIT_LIT
284 const xOf = (index: number) =>
285 left + Math.floor(index / grid.perDay) * (grid.day + grid.groupGap) + (index % grid.perDay) * (grid.tile + grid.gap)
286 const radius = grid.tile >= 7 ? 1.5 : 1.25
287 const parts: string[] = []
288
289 for (let index = 0; index < grid.count; index++) {
290 parts.push(
291 `<rect x="${xOf(index)}" y="${top}" width="${grid.tile}" height="${grid.tile}" rx="${radius}" fill="${index < lit ? ink : LIMIT_UNLIT}"/>`,
292 )
293 }
294
295 // The week's time left, snapped to the nearest tile edge so it never cuts a tile.
296 if (args.elapsed !== undefined) {
297 const boundary = Math.round((1 - clampTo(args.elapsed, 0, 1)) * grid.count)
298 const tickX =
299 boundary <= 0
300 ? left - 2
301 : boundary >= grid.count
302 ? tilesRight + 2
303 : (xOf(boundary - 1) + grid.tile + xOf(boundary)) / 2
304
305 parts.push(`<rect x="${Math.round(tickX - 0.5)}" y="${top - 4}" width="1" height="${grid.tile + 8}" rx="0.5" fill="${LIMIT_TICK}"/>`)
306 }
307
308 const numbers = 'font-variant-numeric:tabular-nums'
309
310 parts.push(
311 `<text x="${tilesRight + GAP_TILES_TEXT}" y="${baseline}" font-family="${LIMIT_FONT}" font-size="12.5" font-weight="600" fill="${used >= 90 ? LIMIT_HOT : LIMIT_TEXT}" style="${numbers}">${Math.round(remaining)}%</text>`,
312 )
313
314 if (showReset) {
315 parts.push(
316 `<text x="${width}" y="${baseline}" text-anchor="end" font-family="${LIMIT_FONT}" font-size="11" fill="${LIMIT_DIM}" style="${numbers}">${(args.reset ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')}</text>`,
317 )
318 }
319
320 return {
321 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${parts.join('')}</svg>`,
322 width,
323 }
324}
325hooks/talk.ts 448 lines1// Codex's live voice and its images, as Nightshift shows them in Claude's window.
2// The voice itself runs in voice.mjs; this is what Nightshift reads from it, and
3// how Codex's pictures become cards in the chat.
4
5/** What the voice bridge says, one JSON line at a time. */
6export type BridgeEvent =
7 | { t: 'state'; phase: 'connecting' | 'live' | 'ended'; error?: string }
8 | { t: 'caption'; role: 'user' | 'assistant'; text: string; final: boolean }
9 | { t: 'work'; text: string }
10 | { t: 'image'; path: string }
11 | { t: 'exchange'; you: string; codex: string; images: string[] }
12 | { t: 'claude'; task: string; you: string }
13
14/** Who does the work the voice is asked for. */
15export type Worker = 'codex' | 'claude'
16
17export function readBridge(line: string): BridgeEvent | undefined {
18 try {
19 const event = JSON.parse(line) as { t?: unknown }
20
21 return typeof event.t === 'string' ? (event as BridgeEvent) : undefined
22 } catch {
23 return undefined
24 }
25}
26
27/** Node through a login shell, as Nightshift runs codex, so the user's PATH holds. */
28export function voiceArgv(
29 root: string,
30 cwd: string,
31 socket: string,
32 worker: Worker,
33 browserSocket: string,
34 choice: { model?: string; effort?: string } = {},
35): string[] {
36 return [
37 '/bin/zsh',
38 '-lc',
39 'exec node "$@"',
40 'node',
41 `${root}/hooks/voice.mjs`,
42 cwd,
43 socket,
44 worker,
45 browserSocket,
46 choice.model ?? '-',
47 choice.effort ?? '-',
48 ]
49}
50
51/** The voice strip's state, from what the bridge has said. */
52export type Talk = {
53 phase: 'off' | 'connecting' | 'live' | 'ending'
54 muted: boolean
55 you: string
56 codex: string
57 work: string
58}
59
60export const QUIET: Talk = { phase: 'off', muted: false, you: '', codex: '', work: '' }
61
62export function hear(talk: Talk, event: BridgeEvent): Talk {
63 switch (event.t) {
64 case 'state':
65 return event.phase === 'ended' ? QUIET : talk.phase === 'ending' ? talk : { ...talk, phase: event.phase }
66 case 'caption':
67 return event.role === 'user' ? { ...talk, you: event.text, codex: event.final ? talk.codex : '' } : { ...talk, codex: event.text }
68 case 'work':
69 return { ...talk, work: event.text }
70 default:
71 return talk
72 }
73}
74
75/**
76 * The live mark: five bars that move on their own while the call is live and
77 * lie flat when muted. It animates inside its own frame, so the app never
78 * redraws for it; a redraw re-runs every Nightshift row in the chat.
79 */
80export function liveWave(isMuted: boolean): { source: string; isInteractive: boolean } {
81 const heights = [5, 9, 14, 10, 6]
82 // A frame of its own takes the page's color scheme, with no default margin.
83 const frame = '<style>:root{color-scheme:light dark}html,body,svg{margin:0}</style>'
84 const bar = (height: number, at: number) => {
85 const rest = 3
86 const y = (16 - (isMuted ? rest : height)) / 2
87
88 if (isMuted) {
89 return `<rect x="${at * 5}" y="${y}" width="3" height="${rest}" rx="1.5" fill="#8a8984"/>`
90 }
91
92 const low = Math.max(rest, Math.round(height * 0.35))
93 const values = `${height};${low};${height}`
94 const ys = `${(16 - height) / 2};${(16 - low) / 2};${(16 - height) / 2}`
95 const timing = `dur="${(0.9 + at * 0.13).toFixed(2)}s" begin="-${(at * 0.21).toFixed(2)}s" repeatCount="indefinite"`
96
97 return (
98 `<rect x="${at * 5}" y="${y}" width="3" height="${height}" rx="1.5" fill="#d4d4ce">` +
99 `<animate attributeName="height" values="${values}" ${timing}/>` +
100 `<animate attributeName="y" values="${ys}" ${timing}/>` +
101 '</rect>'
102 )
103 }
104
105 return {
106 isInteractive: !isMuted,
107 source:
108 '<svg xmlns="http://www.w3.org/2000/svg" width="25" height="16" viewBox="0 0 25 16">' +
109 (isMuted ? '' : frame) +
110 heights.map(bar).join('') +
111 '</svg>',
112 }
113}
114
115/** The strip's state word: short, so the captions keep their room. */
116export function talkState(talk: Talk): string {
117 const state =
118 talk.phase === 'connecting'
119 ? 'Connecting'
120 : talk.phase === 'ending'
121 ? 'Ending'
122 : talk.muted
123 ? 'Muted'
124 : talk.work !== ''
125 ? talk.work
126 : 'Listening'
127
128 return state.length > 22 ? `${state.slice(0, 21)}…` : state
129}
130
131/** Claude's answer as the voice can say it: plain words, about a paragraph. */
132export function speakable(answer: string): string {
133 const plain = answer
134 .replace(/```[\s\S]*?```/g, ' (code) ')
135 .replace(/`([^`]*)`/g, '$1')
136 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
137 .replace(/^[#>*\-\s|]+/gm, '')
138 .replace(/[*_~|]/g, '')
139 .replace(/\s+/g, ' ')
140 .trim()
141
142 if (plain.length <= 600) {
143 return plain
144 }
145
146 const cut = plain.slice(0, 600)
147 const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('? '), cut.lastIndexOf('! '))
148
149 return end > 200 ? cut.slice(0, end + 1) : `${cut.trimEnd()}…`
150}
151
152// --- images ----------------------------------------------------------------
153
154/** A spoken exchange as the chat keeps it: Codex's words, then its images. */
155export const VOICE_HEADER = '**Codex** (voice)'
156
157export function voiceReply(codex: string, images: readonly string[]): string {
158 const words = codex.trim() === '' ? '' : `${codex.trim()}\n\n`
159
160 return `${VOICE_HEADER}\n\n${words}${images.map(path => ``).join('\n')}`.trim()
161}
162
163export function isVoiceReply(text: string): boolean {
164 return text.trimStart().startsWith(VOICE_HEADER)
165}
166
167const IMAGE_LINE = /^!\[[^\]]*\]\((\/[^)]+\.(?:png|jpe?g|webp))\)$/
168
169/** Splits the image lines out of a reply's markdown. */
170export function splitImages(markdown: string): { words: string; images: string[] } {
171 const images: string[] = []
172 const words: string[] = []
173
174 for (const line of markdown.split('\n')) {
175 const found = IMAGE_LINE.exec(line.trim())
176
177 if (found?.[1] !== undefined) {
178 images.push(found[1])
179 } else {
180 words.push(line)
181 }
182 }
183
184 return { words: words.join('\n').trim(), images }
185}
186
187/**
188 * Prints an image's size, then a JPEG preview as base64 small enough for a
189 * card (a mod's SVG holds 131,072 characters): 640px first, smaller if not.
190 */
191export const PREVIEW_SCRIPT =
192 'file=$1; out=$(mktemp -t nightshift-preview).jpg; ' +
193 "size=$(sips -g pixelWidth -g pixelHeight $file 2>/dev/null | awk '/pixel/{print $2}' | tr '\\n' ' '); " +
194 'for spec in "640 72" "512 62" "400 55" "320 50"; do ' +
195 'edge=${spec% *}; quality=${spec#* }; ' +
196 'sips -Z $edge -s format jpeg -s formatOptions $quality $file --out $out >/dev/null 2>&1 || exit 1; ' +
197 "data=$(base64 -i $out | tr -d '\\n'); (( ${#data} <= 110000 )) && break; done; " +
198 'rm -f $out; print -r -- $size; print -r -- $data'
199
200export type Preview = { data: string; width: number; height: number }
201
202export function readPreview(stdout: string): Preview | undefined {
203 const [size = '', data = ''] = stdout.trim().split('\n')
204 const [width, height] = size.trim().split(/\s+/).map(Number)
205
206 if (!width || !height || data.length < 100 || data.length > 120000) {
207 return undefined
208 }
209
210 return { data, width, height }
211}
212
213/** The picture of an image card: the preview, corners rounded, at most 396 by 300. */
214export function imageCard(preview: Preview, maxWidth = 396, maxHeight = 300): { source: string; width: number; height: number } {
215 const scale = Math.min(maxWidth / preview.width, maxHeight / preview.height)
216 const width = Math.round(preview.width * scale)
217 const height = Math.round(preview.height * scale)
218
219 return {
220 width,
221 height,
222 source:
223 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
224 `<defs><clipPath id="r"><rect width="${width}" height="${height}" rx="8"/></clipPath></defs>` +
225 `<image href="data:image/jpeg;base64,${preview.data}" width="${width}" height="${height}" clip-path="url(#r)" preserveAspectRatio="xMidYMid slice"/>` +
226 '</svg>',
227 }
228}
229
230/** The PNGs a Codex thread saved since `sinceSeconds`, oldest first. */
231export function imagesScript(threadId: string, sinceSeconds: number): string[] {
232 const safe = threadId.replace(/[^A-Za-z0-9_-]/g, '_')
233
234 return [
235 '/bin/zsh',
236 '-c',
237 'setopt nullglob; for f in ~/.codex/generated_images/$1/*.png(Om); do (( $(stat -f %m $f) >= $2 )) && print -r -- $f; done',
238 'nightshift',
239 safe,
240 String(Math.floor(sinceSeconds)),
241 ]
242}
243
244export const CODEX_TOOL_DESCRIPTION =
245 "Runs a task with OpenAI Codex on the user's own ChatGPT plan and returns Codex's reply, plus any images it made " +
246 '(you see them, and the user sees them in the chat). Use it when the user asks for an image (new, or an edit of one ' +
247 'you pass in `images`; start the prompt with `$imagegen`), or asks to use one of Codex\'s tools or plugins by name: ' +
248 "`@chrome` drives the user's own Chrome, signed in; `@computer-use` works Mac apps; documents, pdf, presentations " +
249 'and spreadsheets make files. Write the prompt as a complete brief; Codex sees nothing else of this chat. ' +
250 'It runs in the project folder and can write files there. Takes about a minute for an image or a browser task.'
251
252/** Claude's own browser and Mac-app tools, which Codex takes over when Tools is Codex. */
253export const HANDED_TOOLS = /^mcp__(Claude_Browser|claude-in-chrome|computer-use)__/
254
255export const TOOLS_TO_CODEX =
256 ' Tools are set to Codex: use this tool for all browser work (begin the prompt with `@chrome`), for Mac apps ' +
257 '(`@computer-use`), for images (`$imagegen`) and for documents, slides and spreadsheets. Your own browser and ' +
258 'computer-use tools are off. Code, files and the shell stay yours.'
259
260export const HANDED_TOOL_NOTE =
261 'Off: the user set Tools to Codex. Do this through mcp__nightshift__codex instead (begin the prompt with @chrome for ' +
262 'the browser, @computer-use for Mac apps).'
263
264export const CODEX_TOOL_SCHEMA = {
265 type: 'object',
266 properties: {
267 prompt: { type: 'string', description: 'The complete brief for Codex' },
268 images: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of images to attach, for edits' },
269 },
270 required: ['prompt'],
271} as const
272
273// --- Codex in Claude's browser pane ----------------------------------------
274
275/** One browser step Codex asks for, as the bridge hands it to Nightshift. */
276export type BrowserCall = { id: number; tool: string; args: Record<string, unknown> }
277
278/** How much Nightshift asks before Codex's browser steps run in Claude's pane. */
279export type BrowserAccess = 'ask' | 'auto'
280
281/** The step in words, for the Allow / Deny row: "Open producthunt.com". */
282export function browserStep(call: BrowserCall): string {
283 const args = call.args
284 const said = typeof args.action_summary === 'string' && args.action_summary.trim() !== '' ? args.action_summary.trim() : undefined
285 const text = (value: unknown) => (typeof value === 'string' ? value : '')
286 const clip = (value: string) => (value.length > 60 ? `${value.slice(0, 59)}…` : value)
287
288 switch (call.tool) {
289 case 'navigate': {
290 const url = text(args.url)
291
292 if (url === 'back' || url === 'forward') {
293 return `Go ${url}`
294 }
295
296 try {
297 return `Open ${new URL(url).host.replace(/^www\./, '')}`
298 } catch {
299 return `Open ${clip(url)}`
300 }
301 }
302 case 'get_page_text':
303 return 'Read the page text'
304 case 'read_page':
305 return 'Read the page'
306 case 'find':
307 return `Find “${clip(text(args.query))}”`
308 case 'form_input':
309 return said ?? 'Fill in a form field'
310 case 'tabs_context':
311 return 'List the tabs'
312 case 'tabs_create':
313 return 'Open a new tab'
314 case 'computer': {
315 const action = text(args.action)
316
317 if (said !== undefined) {
318 return said
319 }
320
321 return action === 'screenshot'
322 ? 'Take a screenshot'
323 : action === 'type'
324 ? `Type “${clip(text(args.text))}”`
325 : action === 'key'
326 ? `Press ${clip(text(args.text))}`
327 : action.includes('click')
328 ? 'Click on the page'
329 : action === 'scroll' || action === 'scroll_to'
330 ? 'Scroll the page'
331 : `${action || 'Use'} the page`
332 }
333 default:
334 return call.tool
335 }
336}
337
338/** Steps that only look: auto-allowed even when asking would be too much. */
339export function isLookOnly(call: BrowserCall): boolean {
340 return (
341 ['get_page_text', 'read_page', 'find', 'tabs_context'].includes(call.tool) ||
342 (call.tool === 'computer' && ['screenshot', 'wait', 'zoom', 'scroll', 'scroll_to', 'hover'].includes(String(call.args.action)))
343 )
344}
345
346export function readCall(text: string): BrowserCall | undefined {
347 try {
348 const call = JSON.parse(text) as Partial<BrowserCall>
349
350 return typeof call.id === 'number' && typeof call.tool === 'string'
351 ? { id: call.id, tool: call.tool, args: (call.args ?? {}) as Record<string, unknown> }
352 : undefined
353 } catch {
354 return undefined
355 }
356}
357
358// --- Codex's voice, live in the chat ------------------------------------------
359
360/** The row a reply Codex speaks on its own opens with; the chat hides it. */
361export const SPOKE = '(Codex spoke)'
362
363/**
364 * One spoken reply on its way into the chat. The voice bridge feeds it as
365 * Codex talks; the chat row reads it as it fills, and closes with the images.
366 */
367export class VoiceFeed {
368 private chunks: string[] = []
369 private images: string[] = []
370 private segment = ''
371 private segments = 0
372 private isClosed = false
373 private wake: (() => void) | undefined
374
375 /** Codex's words so far in this part of the reply (each caption repeats the whole part). */
376 assistant(text: string, isFinal: boolean): void {
377 if (this.isClosed) {
378 return
379 }
380
381 const lead = this.segment === '' && this.segments > 0 ? ' ' : ''
382 const fresh = text.startsWith(this.segment) ? text.slice(this.segment.length) : ''
383
384 if (fresh !== '') {
385 this.push(`${lead}${fresh}`)
386 this.segment = text
387 }
388
389 if (isFinal) {
390 this.segment = ''
391 this.segments += 1
392 }
393 }
394
395 image(path: string): void {
396 if (!this.isClosed && !this.images.includes(path)) {
397 this.images.push(path)
398 this.notify()
399 }
400 }
401
402 close(): void {
403 this.isClosed = true
404 this.notify()
405 }
406
407 /** Whether Codex has said anything in this reply yet. */
408 hasWords(): boolean {
409 return this.segments > 0 || this.segment !== ''
410 }
411
412 /** The words as they come, then each image as its own markdown line. */
413 async *read(): AsyncGenerator<string> {
414 let shown = 0
415
416 for (;;) {
417 while (this.chunks.length > 0) {
418 yield this.chunks.shift() ?? ''
419 }
420
421 while (shown < this.images.length) {
422 yield `\n\n`
423 shown += 1
424 }
425
426 if (this.isClosed && this.chunks.length === 0 && shown === this.images.length) {
427 return
428 }
429
430 await new Promise<void>(resolve => {
431 this.wake = resolve
432 })
433 }
434 }
435
436 private push(text: string): void {
437 this.chunks.push(text)
438 this.notify()
439 }
440
441 private notify(): void {
442 const wake = this.wake
443
444 this.wake = undefined
445 wake?.()
446 }
447}
448hooks/codex-frames.ts 13 lines1// Generated from the Codex CLI's own logo renderer (openai/codex, Apache-2.0;
2// see NOTICE): codex-rs/tui/src/empty_state_animation, ported line for line
3// and run at 20 columns by 7 rows: the Codex mark at rest, its first frame.
4// Each row is its Braille characters and each character's color ('' blank).
5// Regenerate rather than edit.
6
7export const FRAME_COLUMNS = 20
8export const FRAME_ROWS = 7
9
10export const FRAMES: readonly (readonly (readonly [string, string])[])[] = [
11 [['⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀', ',,,,,,,,#d4d4ce,#e2dccb,#e3ddcc,#b5bdc5,,,,,,,,'], ['⠀⠀⠀⠀⠀⢀⣴⣿⠿⠛⠛⠿⣿⣶⡄⠀⠀⠀⠀⠀', ',,,,,#d4d5d2,#c5c6c3,#93a0ae,#748296,#758092,#7c8798,#838d9c,#99a1ab,#adb6be,#92a3b1,,,,,'], ['⠀⠀⠀⠀⢀⣾⡟⢡⣤⠀⠀⠀⠀⢻⣿⡄⠀⠀⠀⠀', ',,,,#aeb8c6,#b6babc,#627f95,#a1adb1,#a0b6be,,,,,#6e7b91,#aeb5bf,#7b8ba2,,,,'], ['⠀⠀⠀⠀⢸⣿⠁⢈⣿⡇⣀⣀⡀⠀⣿⡇⠀⠀⠀⠀', ',,,,#c2c6c7,#8b96a5,#457c94,#aab4c2,#c6c8c6,#74889d,#afbecd,#78a0b4,#5b9bb1,,#a5aebb,#99a4b4,,,,'], ['⠀⠀⠀⠀⠈⣿⣧⡘⠛⠀⠛⠛⠛⣰⣿⠃⠀⠀⠀⠀', ',,,,#7c8aa0,#aab1bb,#799daf,#a3bbc0,#8a9fae,,#cecfcb,#c3c6c7,#99a3b0,#abb5c1,#b9bdc1,#596a86,,,,'], ['⠀⠀⠀⠀⠀⠈⠻⣿⣶⣤⣤⣴⣾⡿⠃⠀⠀⠀⠀⠀', ',,,,,#afb6bf,#b8bdc2,#a3b2bb,#a3b9bf,#9db0bc,#a8b6bf,#bdc2c7,#b2bac5,#939daa,#718096,,,,,'], ['⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀', ',,,,,,,,#808ea2,#b9bfc5,#b4bbc3,#8290a6,#344868,,,,,,,']],
12]
13