SLOPSHOPPER

order-up

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.

guardcommandtoastpromptmodelprocess
v0.4.0MITupdated 2026-10-19yugantar7/order-up
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · order-up
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ order-up │ ⏺ Read(src/auth.ts) │ 🍳 order-up is installed. Run /order-up │ ⎿ Read 6 lines │ setup to build the chef and choose how it │ ⏺ Update(src/auth.ts) │ behaves (2 minutes). │ ⎿ 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 › /order-up ⎿ order-up: order-up is on (the chef app is not built: run /order-up setup). ⎿ order-up: This session: app in ghostty. ⎿ order-up: Orders for turns over 30s; summary: prompt; needs you: on; bottom-right; sound on; menu bar while Claude runs. ⎿ order-up: Usage: /order-up [setup|test|on|off|status] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🍳 order-up

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.

What it does

  • Order up: a turn that took a while (30s by default) finishes in a session you're not looking at → the chef hops in, rings a bell and lifts the lid on that project's dish. Each project gets its own dish and color.
  • Needs you, and answer from the chef: a session stops to ask you something or wants a permission → a ✋ order, with the question. When it's a question with choices, the choices are buttons: click one and Claude gets your answer without you switching tabs (permission prompts still need the tab).
  • Burnt dish: the turn ended in an error, or its last command failed → the dish comes out burnt 🔥 and the chef frowns.
  • The bill: each order shows how long it took, what it cost, how many files it changed and commands it ran.
  • One chef, one list: several orders stack in one bubble; past about four orders' height the list scrolls. Click one to jump to its session; it also leaves by itself once you look at that session; ✕ clears them all.
  • Snooze: the ⏰ on an order puts it away for 10 minutes; it comes back with a hop. Right-click the menu bar chef to snooze them all.
  • Gets impatient: leave an order waiting and the chef starts rattling the lid, more after five minutes.
  • Menu bar chef: a tiny chef in your menu bar, with the number of orders waiting (or ✋ 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.
  • With no Claude running: by default the menu bar chef leaves when your last session ends and comes back with the next one. Answer "Always" in setup and it stays in the menu bar and starts at login ("Nothing cooking" until a session starts).

Pick your character

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.

A closer look

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

FAQ

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.

Install

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.

Updating

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.

Terminals

Where Claude Code runsClick an order →
Terminal.app, iTerm2that exact tab
Ghostty, Warp, VS Code, Cursor, the Claude desktop appthat 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.

Commands

  • /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 / on

Settings

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

NameValuesWhat it does
skinchef · barista · sushi · robotwho serves your orders
afterevery turn · 30s · 1 min · 2 min · 5 minhow long a turn must run before it becomes an order
summaryyour prompt · haiku summary · nothingwhat each order says (a Haiku summary costs about $0.001 a turn, billed to your Claude account)
needsYouon · offalso pop up when a session asks you something or wants a permission
cornerbottom right · bottom left · top rightwhere the chef stands
soundon · offring a bell when an order is up
menubaron · offthe tiny chef in the menu bar
alwaysOnon · offkeep 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": "🌮" } } }

Privacy

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.

Uninstall

claude plugin uninstall order-up@order-up
rm -f ~/Library/LaunchAgents/dev.order-up.chef.plist
rm -rf ~/.claude/order-up

How it works

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.

Source 1 files
hooks/register.ts 529 lines
1import 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