A tiny chef pops up on your Mac when a Claude Code session finishes (or needs you) in a tab you're not looking at. Click to jump there.

A Claude Code mod that tells you when a session is done. When a Claude Code session finishes, or needs you, in a terminal tab you're not looking at, a tiny chef pops up in the corner of your Mac with the order. Answer it right there, snooze it, or click it and you're back in that exact tab.
🌐 yugantar7.github.io/order-up
Built for people who run several Claude Code sessions at once and lose track of which one finished.
✋ when a session needs you, 2🍳 while sessions cook). Click it to call the chef out any time: it shows the orders, or, with none waiting, the kitchen: every Claude session, what it's doing and for how long; click one to jump there. Click the icon again (or ✕) to send the chef back; right-click for the menu.Four skins, each with its own words and dishes: the chef ("Order up!", "is cooked!"), a barista ("Coffee's up!", "is brewed!"), a sushi chef ("Irasshaimase!", "is plated!") and a robot ("Task complete", "hit an error"). Pick one in setup, or any time with /order-up set skin barista.
<table> <tr> <td align="center" valign="top"><img src="docs/answer.png" width="330" alt="Eight orders scrolling in the bubble: a question with short answers as pills, a question with long answers as full-width buttons, a burnt dish and a cooked one; each has a snooze clock"><br><sub><b>Answer from the chef</b>: short answers are pills, long ones get full-width buttons; ⏰ snoozes; past four orders the list scrolls</sub></td> <td align="center" valign="top"><img src="docs/kitchen.png" width="300" alt="The kitchen: a session that needs you, two cooking, one ready, and resting sessions as chips"><br><sub><b>The kitchen</b>: click the menu bar chef to see every session</sub></td> </tr> </table>
How do I get notified when Claude Code finishes? Install order-up: when a Claude Code turn that ran for a while finishes in a tab you're not looking at, the chef pops up with the result. Click it to jump back.
What is a Claude Code mod? A mod changes Claude Code itself (how it looks, behaves, or what it can do). Mods are plugins with hooks; order-up is one.
Can I answer Claude Code's questions without switching tabs? Yes, for questions with choices: they show as buttons on the chef. Permission prompts still need the tab.
How do I keep track of many Claude Code sessions? Click the menu bar chef to see the kitchen: every session, its state and what it's doing.
Needs macOS and Apple's Command Line Tools (for Swift; xcode-select --install if you don't have them).
claude plugin marketplace add yugantar7/order-up
claude plugin install order-up@order-up
Or inside Claude Code: /plugin install order-up --marketplace yugantar7/order-up. Then start a new session and run:
/order-up setup
Setup checks your Mac, asks a few quick questions (your character, when to pop up, what each order says, needs-you on/off, corner, sound, menu bar, and whether the menu bar chef stays when no Claude session runs), builds the chef app and serves a test order. Run it again any time to change your answers.
Claude Code doesn't update plugins from other people's marketplaces on its own until you say so. Turn it on once: run /plugin, open Marketplaces, pick order-up, choose Enable auto-update. New versions then arrive by themselves, and Claude Code tells you "Plugin updated: order-up · Run /reload-plugins to apply" (a new session picks it up too).
Or update by hand:
claude plugin marketplace update order-up
claude plugin update order-up@order-up
The chef app catches up by itself: the first session after an update rebuilds it in the background and shows "🍳 order-up updated to …". What changed is in the releases.
| Where Claude Code runs | Click an order → |
|---|---|
| Terminal.app, iTerm2 | that exact tab |
| Ghostty, Warp, VS Code, Cursor, the Claude desktop app | that app's window |
The first click in Terminal.app or iTerm2 makes macOS ask whether OrderUp may control it: allow it, or clicks can't switch tabs.
/order-up setup — the setup questions; (re)builds the chef app/order-up config — every setting and its current value/order-up set <name> <value> — change one, in plain words: /order-up set corner top right, /order-up set sound off, /order-up set after 2 min/order-up test — serves a test order/order-up status — what it knows about this session/order-up off / onSet them on the install screen, with /order-up setup or /order-up set, or in Claude Code's own /config menu under order-up. They are the same settings wherever you change them.
| Name | Values | What it does |
|---|---|---|
skin | chef · barista · sushi · robot | who serves your orders |
after | every turn · 30s · 1 min · 2 min · 5 min | how long a turn must run before it becomes an order |
summary | your prompt · haiku summary · nothing | what each order says (a Haiku summary costs about $0.001 a turn, billed to your Claude account) |
needsYou | on · off | also pop up when a session asks you something or wants a permission |
corner | bottom right · bottom left · top right | where the chef stands |
sound | on · off | ring a bell when an order is up |
menubar | on · off | the tiny chef in the menu bar |
alwaysOn | on · off | keep the menu bar chef even with no Claude session running, and start it at login |
A project's own color and dish go in ~/.claude/order-up/settings.json (the project is the git repository's name, else the folder's):
{ "projects": { "web-app": { "color": "#4FD1C5", "dish": "🌮" } } }
order-up makes no network calls of its own. It reads and writes only ~/.claude/order-up/ (plus, if you choose "Always", one login item at ~/Library/LaunchAgents/dev.order-up.chef.plist) (orders, a heartbeat per session, settings and a log of why orders came and went). With summary: "haiku" it asks Claude Haiku for the summary through Claude Code itself. The chef app is built on your Mac from helper/OrderUp.swift; read it before you build it.
claude plugin uninstall order-up@order-up
rm -f ~/Library/LaunchAgents/dev.order-up.chef.plist
rm -rf ~/.claude/order-up
The mod (hooks/register.ts) watches your session: when a turn ends, a question waits, or a permission prompt shows, it writes an order to ~/.claude/order-up/queue/<session>.json and opens the app. The app (helper/OrderUp.swift, one Swift file) draws the chef, checks which app and tab is in front, and jumps there with AppleScript (Terminal.app, iTerm2) or by bringing the app forward. Tests: claude plugin test .
MIT licensed.
hooks/register.ts 529 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3// order-up: when a Claude Code turn finishes (or a session needs you) in a tab you
4// are not looking at, a tiny chef pops up on your Mac with the order. The mod
5// writes orders to ~/.claude/order-up/queue/ and keeps a heartbeat per session in
6// ~/.claude/order-up/sessions/; the OrderUp app (helper/, built by /order-up setup)
7// draws the chef and the menu bar kitchen, and jumps to the tab on a click.
8
9type Settings = {
10 // A turn at least this long raises an order; 0 for every turn.
11 afterSec: number
12 summary: 'prompt' | 'haiku' | 'off'
13 needs: boolean
14 corner: 'bottom-right' | 'bottom-left' | 'top-right'
15 sound: boolean
16 menubar: boolean
17 // Keep the menu bar chef when no Claude session runs, and start it at login.
18 stayOpen: boolean
19 // Who serves the orders: chef, barista, sushi or robot.
20 skin: string
21 // Optional per-project look, by project name: { "my-app": { "color": "#5B9BFF", "dish": "🍜" } }
22 projects?: Record<string, { color?: string; dish?: string }>
23}
24
25const DEFAULTS: Settings = { afterSec: 30, summary: 'prompt', needs: true, corner: 'bottom-right', sound: true, menubar: true, stayOpen: false, skin: 'chef' }
26const LOGIN_AGENT = 'Library/LaunchAgents/dev.order-up.chef.plist'
27const STORE_ON = 'enabled'
28const STORE_TOLD = 'toldAboutSetup'
29// Answers from /order-up setup not yet written to the options (each write reloads the mod).
30const STORE_PENDING = 'pendingOptions'
31
32// The options the manifest declares (`userConfig`): set on install, in /config, by
33// /order-up setup, or /order-up set. Each field, the values it takes, and its meaning.
34const FIELDS: Record<string, { values: readonly (string | boolean)[]; label: string }> = {
35 skin: { values: ['chef', 'barista', 'sushi', 'robot'], label: 'who serves your orders' },
36 after: { values: ['every turn', '30s', '1 min', '2 min', '5 min'], label: 'pop up after a turn of' },
37 summary: { values: ['your prompt', 'haiku summary', 'nothing'], label: 'each order says' },
38 needsYou: { values: [true, false], label: 'pop up when a session needs you' },
39 corner: { values: ['bottom right', 'bottom left', 'top right'], label: 'the chef stands' },
40 sound: { values: [true, false], label: 'ring a bell' },
41 menubar: { values: [true, false], label: 'menu bar chef' },
42 alwaysOn: { values: [true, false], label: 'menu bar chef when Claude is not running (starts at login)' },
43}
44const AFTER: Record<string, number> = { 'every turn': 0, '30s': 30, '1 min': 60, '2 min': 120, '5 min': 300 }
45
46function fromOptions(o: PluginOptions): Settings {
47 const pick = <T>(v: unknown, fallback: T): T => (v === undefined ? fallback : (v as T))
48 return {
49 afterSec: AFTER[String(o.after)] ?? DEFAULTS.afterSec,
50 summary: o.summary === 'haiku summary' ? 'haiku' : o.summary === 'nothing' ? 'off' : 'prompt',
51 needs: pick(o.needsYou, DEFAULTS.needs),
52 corner: o.corner === 'bottom left' ? 'bottom-left' : o.corner === 'top right' ? 'top-right' : 'bottom-right',
53 sound: pick(o.sound, DEFAULTS.sound),
54 menubar: pick(o.menubar, DEFAULTS.menubar),
55 stayOpen: pick(o.alwaysOn, DEFAULTS.stayOpen),
56 skin: ['chef', 'barista', 'sushi', 'robot'].includes(String(o.skin)) ? String(o.skin) : 'chef',
57 }
58}
59
60const PALETTE = ['#5B9BFF', '#FF5C6C', '#C77DFF', '#A3C94A', '#E8B84B', '#4FD1C5', '#F687B3', '#F6AD55', '#9AE6B4', '#90CDF4']
61
62// Walks up to the login process, whose tty is the tab's (Terminal.app and iTerm2
63// both start one per tab; a wrapper such as tmux gets its own pty, so this finds the tab's).
64const FIND_TTY =
65 'p=$$; while [ "$p" -gt 1 ]; do case "$(ps -o comm= -p "$p")" in *login) ps -o tty= -p "$p"; exit;; esac; p=$(ps -o ppid= -p "$p" | tr -d " "); done; ps -o tty= -p $PPID'
66
67type Session = { key: string; tty?: string; bundleId?: string; app: string; title: string; color: string; dish?: string }
68
69let isOn = true
70let settings: Settings = DEFAULTS
71let options: PluginOptions = {}
72let base = ''
73let me: Session | null = null
74let state: 'cooking' | 'needs' | 'ready' | 'idle' = 'idle'
75let stateSince = 0
76// The turn being cooked: when it started, what it cost so far, what it touched.
77let turn: { at: number; usd: number; prompt: string; files: Set<string>; commands: number; lastFailed: boolean } | null = null
78
79function hash(text: string): number {
80 let h = 7
81 for (const ch of text) h = (h * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
82 return h
83}
84
85function short(text: string, n: number): string {
86 const one = text.replace(/\s+/g, ' ').trim()
87 return one.length > n ? `${one.slice(0, n - 1)}…` : one
88}
89
90function span(ms: number): string {
91 const s = Math.round(ms / 1000)
92 if (s < 60) return `${s}s`
93 const m = Math.floor(s / 60)
94 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
95}
96
97async function sh($: EngineInterface, script: string, args: string[] = [], timeoutMs = 10000) {
98 return $.process.run(['/bin/sh', '-c', script, 'sh', ...args], { timeoutMs })
99}
100
101function appPath(): string {
102 return `${base}/OrderUp.app`
103}
104
105// settings.json: the app's copy of the options, plus the project colors and dishes
106// people add by hand (the only part of it they edit).
107async function writeSettings($: EngineInterface): Promise<void> {
108 await $.fs.write(`${base}/settings.json`, JSON.stringify(settings, null, 2))
109}
110
111async function loadSettings($: EngineInterface): Promise<void> {
112 let projects: Settings['projects']
113 if (await $.fs.exists(`${base}/settings.json`)) {
114 try {
115 projects = (JSON.parse(await $.fs.read(`${base}/settings.json`)) as Partial<Settings>).projects
116 } catch {
117 // a half-edited file: no project looks this time
118 }
119 }
120 settings = { ...fromOptions(options), projects }
121}
122
123// Writes options; each write reloads the mod, so what is left waits in the store
124// and the next load finishes it.
125async function applyOptions($: EngineInterface, want: Record<string, string | boolean>): Promise<void> {
126 await $.store.set(STORE_PENDING, want)
127 for (const [key, value] of Object.entries(want)) {
128 if (options[key] === value) continue
129 const r = await $.config.set({ key: `order-up.${key}`, value })
130 if ('deny' in r && r.deny) throw new Error(`could not set ${key}: ${r.deny}`)
131 }
132 await $.store.set(STORE_PENDING, null)
133}
134
135async function finishPending($: EngineInterface): Promise<void> {
136 const want = (await $.store.get(STORE_PENDING)) as Record<string, string | boolean> | null | undefined
137 if (want) await applyOptions($, want)
138}
139
140function describe(): string {
141 const shown = (v: unknown) => (v === true ? 'on' : v === false ? 'off' : String(v))
142 return Object.entries(FIELDS)
143 .map(([k, f]) => ` ${k.padEnd(9)} ${shown(options[k] ?? (k === 'after' ? '30s' : ''))} — ${f.label} (${f.values.map(shown).join(' | ')})`)
144 .join('\n')
145}
146
147// `/order-up set corner top right`: one option by name, its value in plain words.
148async function setOne($: EngineInterface, args: string): Promise<string> {
149 const [name = '', ...rest] = args.trim().split(/\s+/)
150 const key = Object.keys(FIELDS).find(k => k.toLowerCase() === name.toLowerCase() || (name === 'always' && k === 'alwaysOn') || (name === 'needs' && k === 'needsYou'))
151 if (!key) return `Which one? ${Object.keys(FIELDS).join(', ')}. For example: /order-up set corner top right`
152 const raw = rest.join(' ').toLowerCase()
153 const f = FIELDS[key]
154 const value = typeof f.values[0] === 'boolean' ? (['on', 'yes', 'true'].includes(raw) ? true : ['off', 'no', 'false'].includes(raw) ? false : undefined) : f.values.find(v => v === raw)
155 if (value === undefined) return `${key} takes: ${f.values.map(v => (v === true ? 'on' : v === false ? 'off' : v)).join(' | ')}`
156 await applyOptions($, { [key]: value })
157 return `Set ${key} to ${value === true ? 'on' : value === false ? 'off' : value}.`
158}
159
160// Which project this is (the git repo, else the folder), and the terminal tab it runs in.
161async function identify($: EngineInterface): Promise<void> {
162 const cwd = await $.session.cwd()
163 const top = (await sh($, 'git -C "$1" rev-parse --show-toplevel 2>/dev/null', [cwd])).stdout.trim()
164 const root = top || cwd
165 const name = root.split('/').filter(Boolean).pop() ?? 'claude'
166 const sub = top && cwd.startsWith(`${top}/`) ? cwd.slice(top.length + 1) : ''
167 const title = sub ? `${name} · ${sub}` : name
168 const look = settings.projects?.[name] ?? {}
169 const term = (await $.env.get('TERM_PROGRAM')) ?? ''
170 const bundleId = await $.env.get('__CFBundleIdentifier')
171 const ttyName = (await sh($, FIND_TTY)).stdout.trim().split('\n')[0]
172 const tty = /^ttys\d+$/.test(ttyName) ? `/dev/${ttyName}` : undefined
173 const id = await $.session.id()
174 me = {
175 key: tty ? ttyName : `session-${id.slice(0, 8)}`,
176 tty,
177 bundleId,
178 app: term,
179 title,
180 color: look.color ?? PALETTE[hash(name) % PALETTE.length],
181 dish: look.dish,
182 }
183}
184
185async function wake($: EngineInterface): Promise<void> {
186 if (!(await $.fs.exists(`${appPath()}/Contents/MacOS/OrderUp`))) return
187 await $.process.run(['/usr/bin/open', '-g', '-a', appPath()], { timeoutMs: 10000 })
188}
189
190async function heartbeat($: EngineInterface, next?: typeof state, activity?: string): Promise<void> {
191 if (!me || !isOn) return
192 const now = await $.clock.now()
193 if (next && next !== state) {
194 state = next
195 stateSince = now
196 }
197 await $.fs.write(`${base}/sessions/${me.key}.json`, JSON.stringify({ ...me, state, since: stateSince, activity, updatedAt: now }))
198}
199
200async function summaryOf($: EngineInterface, prompt: string, answer: string): Promise<string | undefined> {
201 if (settings.summary === 'off') return undefined
202 if (settings.summary === 'prompt') return short(prompt.split('\n')[0], 80) || undefined
203 const r = await $.model.complete({
204 model: 'haiku',
205 system: 'Say in at most 8 words what the assistant got done, past tense, sentence case, no period. Reply with only that.',
206 prompt: `The request:\n${prompt.slice(0, 1200)}\n\nThe reply:\n${answer.slice(-1500)}`,
207 maxTokens: 40,
208 effort: 'low',
209 timeoutMs: 15000,
210 })
211 return r.isAnswered ? short(r.text.split('\n')[0].replace(/^["']|["'.]$/g, ''), 80) : short(prompt, 80)
212}
213
214async function order(
215 $: EngineInterface,
216 kind: 'done' | 'burnt' | 'needs',
217 summary?: string,
218 bill?: string,
219 isTest = false,
220 question?: { options: string[]; answerId: string },
221): Promise<void> {
222 if (!me) return
223 const at = (await $.clock.now()) / 1000
224 await $.fs.write(
225 `${base}/queue/${me.key}.json`,
226 JSON.stringify({ tty: me.tty, bundleId: me.bundleId, app: me.app, title: me.title, color: me.color, dish: me.dish, summary, bill, kind, at, isTest, ...question }),
227 )
228 await wake($)
229}
230
231// After an update the mod is new but the chef app is the one setup built: rebuild it
232// (in the background), restart it, and say so.
233async function catchUp($: EngineInterface): Promise<void> {
234 const mine = (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }).version
235 const built = (await sh($, 'defaults read "$1/Contents/Info" CFBundleShortVersionString 2>/dev/null', [appPath()])).stdout.trim()
236 if (!mine || built === mine) return
237 const r = await sh($, 'sh "$1/helper/build.sh" "$2"', [$.plugin.root, appPath()], 180000)
238 if (r.exitCode !== 0) {
239 $.ui.toast('🍳 order-up was updated, but its chef app did not rebuild: run /order-up setup.', { timeoutMs: 12000 })
240 return
241 }
242 await sh($, 'pkill -x OrderUp; sleep 0.5')
243 await wake($)
244 $.ui.toast(`🍳 order-up updated to ${mine}. What's new: github.com/yugantar7/order-up/releases`, { timeoutMs: 12000 })
245}
246
247// A login item (a LaunchAgent that opens the app) while stayOpen is on; removed otherwise.
248async function startAtLogin($: EngineInterface, on: boolean): Promise<void> {
249 const home = (await $.env.get('HOME')) ?? ''
250 const plist = `${home}/${LOGIN_AGENT}`
251 if (!on) {
252 await $.process.run(['/bin/rm', '-f', plist], { timeoutMs: 5000 })
253 return
254 }
255 await $.fs.write(
256 plist,
257 `<?xml version="1.0" encoding="UTF-8"?>
258<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
259<plist version="1.0">
260<dict>
261 <key>Label</key><string>dev.order-up.chef</string>
262 <key>ProgramArguments</key><array><string>/usr/bin/open</string><string>-g</string><string>-a</string><string>${appPath()}</string></array>
263 <key>RunAtLoad</key><true/>
264</dict>
265</plist>
266`,
267 )
268}
269
270// The setup interview: checks the Mac, asks how the chef should behave, builds the
271// app, and serves a test order.
272async function setup($: EngineInterface): Promise<string> {
273 const notes: string[] = []
274 if ((await sh($, 'uname -s')).stdout.trim() !== 'Darwin') return 'order-up runs on macOS only (it draws a native window).'
275 if ((await sh($, 'xcode-select -p >/dev/null 2>&1 && command -v swiftc')).exitCode !== 0) {
276 return 'order-up builds its small chef app with Swift, which comes with Apple\'s Command Line Tools. Run `xcode-select --install` in a terminal, wait for it to finish, then run /order-up setup again.'
277 }
278 const ask = (q: string, choices: string[], header: string) => $.ui.ask(q, { options: choices, header })
279 const want: Record<string, string | boolean> = {}
280
281 const who = await ask('Who should serve your orders?', ['Chef (Recommended)', 'Barista', 'Sushi chef', 'Robot'], 'Character')
282 want.skin = who.startsWith('Barista') ? 'barista' : who.startsWith('Sushi') ? 'sushi' : who.startsWith('Robot') ? 'robot' : 'chef'
283 const when = await ask('When should it pop up?', ['Turns over 30s (Recommended)', 'Turns over 2 min', 'Every finished turn'], 'When')
284 want.after = when.startsWith('Every') ? 'every turn' : when.includes('2 min') ? '2 min' : '30s'
285 const what = await ask('What should each order say?', ['Your prompt, shortened (free)', 'A Haiku summary (~$0.001/turn)', 'Nothing, just the project'], 'Summary')
286 want.summary = what.startsWith('A Haiku') ? 'haiku summary' : what.startsWith('Nothing') ? 'nothing' : 'your prompt'
287 const needs = await ask('Also pop up when a session needs you (a question or a permission prompt)?', ['Yes (Recommended)', 'No'], 'Needs you')
288 want.needsYou = !needs.startsWith('No')
289 const corner = await ask('Where on the screen should the chef stand?', ['Bottom right (Recommended)', 'Bottom left', 'Top right'], 'Corner')
290 want.corner = corner.startsWith('Bottom left') ? 'bottom left' : corner.startsWith('Top') ? 'top right' : 'bottom right'
291 const sound = await ask('Ring a bell when an order is up?', ['Yes (Recommended)', 'No sound'], 'Sound')
292 want.sound = !sound.startsWith('No')
293 const bar = await ask('Put a tiny chef in the menu bar (click it to call the chef out and see every session)?', ['Yes (Recommended)', 'No'], 'Menu bar')
294 want.menubar = !bar.startsWith('No')
295 want.alwaysOn = false
296 if (want.menubar) {
297 const stay = await ask(
298 'Keep the chef in the menu bar even when no Claude session is running (it starts at login, and a click calls it out any time)?',
299 ['Only while Claude runs (Recommended)', 'Always, and start at login'],
300 'Always on',
301 )
302 want.alwaysOn = stay.startsWith('Always')
303 }
304 // This setup goes on with the new answers; the options are written last.
305 settings = { ...fromOptions({ ...options, ...want }), projects: settings.projects }
306 await writeSettings($)
307
308 const built = await sh($, 'sh "$1/helper/build.sh" "$2"', [$.plugin.root, appPath()], 180000)
309 if (built.exitCode !== 0) return `Settings saved, but the chef app did not build:\n${built.stderr.trim().slice(-800)}`
310 notes.push('Built the chef app.')
311 await startAtLogin($, settings.stayOpen)
312 if (settings.stayOpen) notes.push('The chef now lives in your menu bar and starts at login (right-click it → Quit to close it until the next Claude session).')
313
314 await identify($)
315 if (me?.app === 'Apple_Terminal' || me?.app === 'iTerm.app') {
316 notes.push(`The first time you click an order, macOS asks to let OrderUp control ${me.app === 'iTerm.app' ? 'iTerm' : 'Terminal'}: allow it, so a click can bring the right tab forward.`)
317 } else if (me?.bundleId) {
318 notes.push('In this terminal a click brings its window forward (tab-exact jumping needs Terminal.app or iTerm2).')
319 } else {
320 notes.push('This terminal does not say which app it is, so a click cannot bring it forward; the chef still tells you what finished.')
321 }
322 await order($, 'done', 'Setup complete: this is a test order', `${span(1000 * 42)} · $0.12 · 2 files · 3 cmds`, true)
323 const after = settings.afterSec ? `turns over ${settings.afterSec}s` : 'every turn'
324 return [
325 '🍳 order-up is set up. A test order is on your screen now (click it or ✕).',
326 `The chef will pop up for ${after} that finish in a tab you are not looking at${settings.needs ? ', and when a session needs you' : ''}.`,
327 ...notes,
328 'Change anything later with /order-up setup, /order-up set (see /order-up config), or /config → order-up. A project\'s own color and dish go in ~/.claude/order-up/settings.json under "projects".',
329 ].join('\n')
330}
331
332async function setupThenSave($: EngineInterface): Promise<string> {
333 const text = await setup($)
334 // Last: writing the options reloads the mod; a load finishes any left over.
335 const want = settingsToOptions(settings)
336 void applyOptions($, want).catch(() => {})
337 return text
338}
339
340function settingsToOptions(x: Settings): Record<string, string | boolean> {
341 const after = Object.entries(AFTER).find(([, n]) => n === x.afterSec)?.[0] ?? '30s'
342 return {
343 after,
344 summary: x.summary === 'haiku' ? 'haiku summary' : x.summary === 'off' ? 'nothing' : 'your prompt',
345 needsYou: x.needs,
346 corner: x.corner.replace('-', ' '),
347 sound: x.sound,
348 menubar: x.menubar,
349 alwaysOn: x.stayOpen,
350 skin: x.skin,
351 }
352}
353
354export const register: Register = (on, opts) => {
355 options = opts
356
357 on('session.start', async ($, e, next) => {
358 await $.command.register({
359 name: 'order-up',
360 description: 'The order-up chef: "setup" asks how it should behave and builds it; "config" shows the settings; "set <name> <value>" changes one; "test"; "on"/"off"',
361 argumentHint: '[setup|config|set <name> <value>|test|on|off|status]',
362 })
363 try {
364 base = `${(await $.env.get('HOME')) ?? ''}/.claude/order-up`
365 isOn = (await $.store.get(STORE_ON)) !== false
366 await loadSettings($)
367 await identify($)
368 const isBuilt = await $.fs.exists(`${appPath()}/Contents/MacOS/OrderUp`)
369 if (!isBuilt && (await $.store.get(STORE_TOLD)) !== true) {
370 await $.store.set(STORE_TOLD, true)
371 $.ui.toast('🍳 order-up is installed. Run /order-up setup to build the chef and choose how it behaves (2 minutes).', { timeoutMs: 15000 })
372 }
373 if (isBuilt) {
374 // The app reads its copy of the options; the login item follows "alwaysOn".
375 await writeSettings($)
376 await startAtLogin($, settings.stayOpen && settings.menubar)
377 void catchUp($).catch(() => {})
378 }
379 void finishPending($).catch(() => {})
380 // When this session's state began: now, not 1970.
381 stateSince = await $.clock.now()
382 if (isOn) {
383 await heartbeat($, 'idle')
384 if (settings.menubar) await wake($)
385 }
386 // Stay on the kitchen board: a session silent for three minutes counts as gone.
387 $.clock.every(60000, () => void heartbeat($).catch(() => {}))
388 } catch {
389 // no tab, no HOME, a -p run: the chef sits this session out
390 }
391 return next(e)
392 })
393
394 on('session.end', async ($, e, next) => {
395 if (me) await $.process.run(['/bin/rm', '-f', `${base}/sessions/${me.key}.json`], { timeoutMs: 5000 }).catch(() => {})
396 return next(e)
397 })
398
399 on('prompt.submit', async ($, e, next) => {
400 if (isOn && !e.isMeta && !e.command) {
401 const usage = await $.session.usage().catch(() => undefined)
402 turn = { at: await $.clock.now(), usd: usage?.cost?.usd ?? 0, prompt: e.text, files: new Set(), commands: 0, lastFailed: false }
403 void heartbeat($, 'cooking', short(e.text.split('\n')[0], 80)).catch(() => {})
404 }
405 return next(e)
406 })
407
408 // What the turn touched, for the bill; a failing last command burns the dish.
409 on('tool.call', async ($, e, next) => {
410 const ran = await next(e)
411 if (!turn || e.agentId !== undefined) return ran
412 const args = e as unknown as { file_path?: unknown; notebook_path?: unknown }
413 if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(e.tool)) {
414 const file = args.file_path ?? args.notebook_path
415 if (typeof file === 'string' && !ran.isError) turn.files.add(file)
416 }
417 if (e.tool === 'Bash') {
418 turn.commands++
419 turn.lastFailed = ran.isError === true
420 }
421 return ran
422 })
423
424 on('turn.complete', async ($, e, next) => {
425 if (!isOn || e.agentId !== undefined || !turn) return next(e)
426 const t = turn
427 turn = null
428 if (e.isAborted) {
429 void heartbeat($, 'idle').catch(() => {})
430 return next(e)
431 }
432 void (async () => {
433 const burnt = e.reason === 'error' || t.lastFailed
434 if (e.durationMs >= settings.afterSec * 1000) {
435 const usage = await $.session.usage().catch(() => undefined)
436 const usd = Math.max(0, (usage?.cost?.usd ?? t.usd) - t.usd)
437 const parts = [span(e.durationMs)]
438 if (usage?.cost) parts.push(`$${usd.toFixed(2)}`)
439 if (t.files.size) parts.push(`${t.files.size} file${t.files.size === 1 ? '' : 's'}`)
440 if (t.commands) parts.push(`${t.commands} cmd${t.commands === 1 ? '' : 's'}`)
441 await order($, burnt ? 'burnt' : 'done', await summaryOf($, t.prompt, e.answer), parts.join(' · '))
442 }
443 await heartbeat($, 'ready')
444 })().catch(() => {})
445 return next(e)
446 })
447
448 // A question waiting on you (Claude's, or another mod's) or a permission prompt.
449 // A one-question card with choices can be answered from the chef: the card here
450 // and the chef's buttons race, and the first answer wins.
451 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
452 const card = e.questions.length === 1 ? e.questions[0] : undefined
453 const choices = card && !card.multiSelect ? card.options.map(o => o.label).slice(0, 4) : []
454 const answerId = e.tool_use_id.replace(/[^\w-]/g, '')
455 const canAnswer = isOn && settings.needs && choices.length > 0 && answerId !== ''
456 if (isOn && settings.needs) {
457 const q = card?.question ?? e.questions[0]?.question ?? 'a question'
458 void order($, 'needs', `asks: ${short(q, 70)}`, undefined, false, canAnswer ? { options: choices, answerId } : undefined).catch(() => {})
459 void heartbeat($, 'needs').catch(() => {})
460 }
461 const dialog = next(e)
462 if (!canAnswer || !card) {
463 const answered = await dialog
464 void heartbeat($, 'cooking').catch(() => {})
465 return answered
466 }
467 // Waits for the chef's answer file in a small shell loop (its time is not this hook's).
468 const file = `${base}/answers/${answerId}.json`
469 const waiter = $.process.spawn({ argv: ['/bin/sh', '-c', 'while [ ! -f "$1" ]; do sleep 0.4; done; cat "$1"; rm -f "$1"', 'sh', file] })
470 const fromChef = (async () => {
471 let out = ''
472 for await (const piece of waiter) if ('text' in piece) out += piece.text
473 return out
474 })().catch(() => new Promise<string>(() => {}))
475 const first = await Promise.race([dialog.then(r => ({ r }), err => ({ err })), fromChef.then(text => ({ text }))])
476 void heartbeat($, 'cooking').catch(() => {})
477 if ('text' in first) {
478 let answer = ''
479 try {
480 answer = String((JSON.parse(first.text) as { answer?: unknown }).answer ?? '')
481 } catch {
482 // a torn write: fall through to the card
483 }
484 if (choices.includes(answer)) return { result: { questions: e.questions, answers: { [card.question]: answer } } }
485 return dialog
486 }
487 // Answered here: stop waiting, and take the chef's order back.
488 void waiter.return?.(undefined as never)
489 void sh($, 'grep -q "$2" "$1" 2>/dev/null && rm -f "$1"', [`${base}/queue/${me?.key ?? 'none'}.json`, answerId]).catch(() => {})
490 if ('err' in first) throw first.err
491 return first.r
492 })
493
494 on('classic.Notification', async ($, e, next) => {
495 if (isOn && settings.needs && (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog')) {
496 void order($, 'needs', short(e.notification_type === 'elicitation_dialog' ? 'waiting for your answer' : e.message, 80)).catch(() => {})
497 void heartbeat($, 'needs').catch(() => {})
498 }
499 return next(e)
500 })
501
502 on('command.run', { command: 'order-up' }, async ($, e) => {
503 const arg = e.args.trim().toLowerCase()
504 if (arg === 'setup') return { text: await setupThenSave($) }
505 if (arg === 'config') {
506 return { text: `order-up settings:\n${describe()}\nChange one with /order-up set <name> <value> (e.g. /order-up set corner top right), all of them with /order-up setup, or in /config → order-up.` }
507 }
508 if (arg.startsWith('set ') || arg === 'set') return { text: await setOne($, e.args.trim().slice(3)) }
509 if (arg === 'test') {
510 await order($, 'done', 'A test order from /order-up test', `${span(1000 * 95)} · $0.21 · 1 file · 2 cmds`, true)
511 return { text: (await $.fs.exists(`${appPath()}/Contents/MacOS/OrderUp`)) ? 'Test order served.' : 'The chef app is not built yet: run /order-up setup.' }
512 }
513 if (arg === 'on' || arg === 'off') {
514 isOn = arg === 'on'
515 await $.store.set(STORE_ON, isOn)
516 return { text: isOn ? 'order-up on.' : 'order-up off: no more orders.' }
517 }
518 const built = await $.fs.exists(`${appPath()}/Contents/MacOS/OrderUp`)
519 return {
520 text: [
521 `order-up is ${isOn ? 'on' : 'off'}${built ? '' : ' (the chef app is not built: run /order-up setup)'}.`,
522 `This session: ${me?.title ?? '?'} in ${me?.app || 'an unknown terminal'}${me?.tty ? ` (${me.tty})` : ''}.`,
523 `Orders for ${settings.afterSec ? `turns over ${settings.afterSec}s` : 'every turn'}; summary: ${settings.summary}; needs you: ${settings.needs ? 'on' : 'off'}; ${settings.corner}; sound ${settings.sound ? 'on' : 'off'}; menu bar ${settings.menubar ? (settings.stayOpen ? 'always, from login' : 'while Claude runs') : 'off'}.`,
524 'Usage: /order-up [setup|test|on|off|status]',
525 ].join('\n'),
526 }
527 })
528}
529