SLOPSHOPPER

Nightshift

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…

newbandspinnerrowsguardcommand
v0.4.6Apache-2.0updated 2026-10-04hacksurvivor/claude-mods/mods/nightshift
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nightshift
› 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 › /nightshift ⎿ nightshift: Nightshift is on auto. Commands: /nightshift auto, /nightshift codex, /nightshift claude. ⎿ nightshift: ⎿ nightshift: Claude limits: ⎿ nightshift: - 5-hour: 31%, resets Thu 02:53 ⎿ nightshift: ⎿ nightshift: Codex has not answered in this session yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ Codex Medium
README

Nightshift: a crescent moon carrying a lantern

Nightshift

Codex inside Claude Code. Nightshift is a Claude Code mod that puts OpenAI's Codex one click away in the same window:

  • When your Claude usage limit runs out, Codex picks up the same conversation and answers in the chat. When the limit resets, Claude takes it back and is told what Codex did.
  • A Codex strip above the message box shows Codex's effort and model pickers, its 7-day limit as a week of tiles, a Tools switch and a Talk button.
  • Talk opens Codex's live voice. Each spoken exchange lands in the chat. Say "pass this to Claude" (or switch Work to Claude) and Claude does the work, then the voice reads you its answer.
  • Images: ask Claude for an image and it asks Codex, which makes it with Codex's own image generator on your ChatGPT plan. The image shows in the chat.
  • Tools: Codex hands browser work, Mac apps, images and documents to Codex. When Codex browses, it browses in Claude's own browser pane, so you watch it there.
  • /codex <prompt> answers one message with Codex, whatever the mode.

Nightshift in the Claude desktop app: a Codex reply covering for Claude, a voice exchange, Codex asking to use Claude's browser, and the Codex strip while talking and at rest

Requirements

  • Claude Code 2.1.286 or later (mods). The strip, image cards and browser pane are drawn by the Claude desktop app; the terminal gets a plain-text strip.
  • The Codex CLI 0.159 or later on your PATH, signed in (codex login). A ChatGPT plan works; no OpenAI API key is needed.
  • Node.js 22 or later on your PATH, for Nightshift's two small helper scripts.
  • macOS for voice: it uses the voice helper that ships with the Codex CLI.

Install

In Claude Code:

/plugin marketplace add hacksurvivor/claude-mods
/plugin install nightshift@hacksurvivor
/reload-plugins

Use it

  • The strip: pick Codex's effort and model, see its 7-day limit (lit tiles are what's left; the thin mark is how much of the week is left), switch Tools, press Talk.
  • /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.

What Nightshift runs, reads and sends

Nightshift is open about everything it does on your machine and what leaves it:

  • It runs the Codex CLI from your 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.
  • It sends to OpenAI, through Codex: when Codex picks up the chat, your message and up to the last 20 messages of the conversation (their text and the names of tools used); whatever you send with /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.
  • It reads, from Codex's folder: the usage-limit lines in ~/.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.
  • It runs two helper scripts with Node: 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.
  • Claude's browser pane: when Codex browses, Nightshift carries each of Codex's steps into Claude's browser pane with Claude Code's own connection to it, which does not show Claude Code's permission prompt. Nightshift asks instead: steps that change something (opening a page, clicking, typing, filling a field) wait for your Allow, Allow all or Deny above the strip; steps that only look (reading the page, screenshots, scrolling) run at once. With Tools: Codex · auto, every step runs without asking.
  • Tools: Codex switches off Claude's own browser and computer-use tools (Claude is told they're off, and calls to them are refused) until you set Tools back to Claude. Code, files and the shell stay with Claude.
  • During voice, a permission request from Codex that your own Codex hooks don't answer is declined, so a call never waits on a prompt you can't see.
  • It keeps your choices (mode, model, effort, who works, Tools) in Claude Code's plugin store.

How Nightshift works, call by call

Programs it runs

All on your computer. Nothing is downloaded or installed, and no package manager runs.

ProgramWhenWhy
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 toolTo 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 onThe voice bridge. It runs codex app-server and the voice helper that ships with the Codex CLI.
node hooks/browser-mcp.mjsStarted by Codex during runs Nightshift beginsThe browser bridge: Codex's way into Claude's browser pane.
/bin/zsh -c with ls, grep and tailAt session start, after each Codex run, and every 5 minutesTo read the newest usage-limit line from ~/.codex/sessions, for the 7-day meter.
/bin/zsh -c with cat and awkAt session startTo 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 base64After a Codex run that made imagesTo 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_DIRWhen a bridge startsTo find your private temp folder for the bridges' sockets.

Requests it makes

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:

  • Browser bridge: GET /next (the next step Codex asks for) and POST /result (its result).
  • Voice bridge: 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.

Tools it calls itself

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.

Prompts it submits

  • A voice exchange: what you said, as a chat row. Nightshift answers it with Codex's spoken reply and images, and no model runs.
  • Work you pass to Claude by voice: the task as Codex's voice wrote it. Claude then does the work.
  • /codex <prompt>: your prompt, and Codex answers it.
  • A message that hit Claude's limit mid-reply (auto mode): your last message again, so Codex can answer 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.

Hooks and what they change

  • 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.

Credentials

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.

Notes

  • Voice drives the private voice helper bundled with the Codex CLI, whose protocol is tied to the exact Codex build. A Codex update can break voice until Nightshift is updated; everything else keeps working.
  • Nightshift is not made or endorsed by OpenAI. Codex, its logo and the >_ header are OpenAI's; parts of the Codex CLI's art and shimmer are ported under Apache-2.0 (see NOTICE).

License

Apache-2.0. See LICENSE and NOTICE.

Source 5 files
hooks/register.tsx 1562 lines
1import 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 => `![image](${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 pump
hooks/codex.ts 389 lines
1// 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"
389
hooks/look.ts 325 lines
1// 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')}</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}
325
hooks/talk.ts 448 lines
1// 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 => `![image](${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![image](${this.images[shown]})`
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}
448
hooks/codex-frames.ts 13 lines
1// 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