SLOPSHOPPER

standby

Standby inside Claude Code: listen to a meeting or talk by voice, with Claude in this session hearing it all

newbandrowsguardcommandtoast
★ 2v0.1.0MITupdated 2026-10-26mksadoughi/standby/lite
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · standby
› 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 › /standby ⎿ standby: Standby isn’t running, and it didn’t open. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Standby inside Claude Code

Install

install.sh adds it to Claude Code for every folder: this repo is a plugin marketplace, and the plugin is standby@standby. By hand (Claude Code 2.1.287 or later):

claude plugin marketplace add <this repo> && claude plugin install standby@standby --scope user

or, inside Claude Code: /plugin install standby --marketplace <this repo>.

To update: git pull in this repo. The plugin loads in place from lite/, so a new Claude Code session has it, or /reload-plugins in one already open (no /plugin update needed). Restart Standby too when its app changed.

How to try it

  1. Type /standby in Claude Code (signed in, Standby running). Nothing shows above the prompt while nothing is live.
  2. During a call or a video, type /standby listen (or ask Claude to listen): a quiet line above the prompt reads "● Listening · … 1m", then Pause, End | Full window (in the terminal, type the letter shown and press Enter), with the latest finished line of the meeting dim under it. Full window opens Standby on this meeting; /standby pause and /standby stop work too. To name a speaker here: /standby name Speaker 1 Nadia.
  3. Ask Claude anything about the meeting in the normal chat. By default the transcript goes with your prompt (all of it the first time, then what's new), so Claude knows without you pasting anything, and nothing fills the chat while you only listen. After the meeting, Claude reads it with Standby's meeting tool. To have it passed as it goes instead, start with /standby listen live, or /standby set transcript live (Standby's Settings has it too).
  4. Press 2 to stop: the summary shows on the band for a while, and stays in Standby. Nothing is sent to Claude, and there's nothing to choose.
  5. What to look for: does anything in the terminal move while you listen (only the minutes should); does Claude answer about the meeting when you ask (it should, without you pasting); with Live, do the meeting notes show in the chat as one dim line, and does Claude reply on its own to them (it shouldn't)?

Settings from Claude Code

/standby settings lists the settings that matter here, each with its value and its choices: how a meeting's transcript reaches Claude (transcript: ask or live), what Standby records (capture), the microphone (mic), the speech model (speech_model), the voice and its model (voice, voice_model), talking over the voice (talk_over), and a few more. /standby set <name> <value> changes one, for example /standby set mic AirPods Pro (transcript switches a meeting already running here too); Standby checks the value and says the choices when it isn't one of them. Or ask Claude in your own words ("pass the meeting to me as it goes"): it uses Standby's settings tool. No need to edit ~/.standby/config.json.

Calls

A call is a voice conversation with this Claude Code session itself: its quick, spoken side, which takes real work deeper in the same session. Start one with /standby call (or ask Claude to call):

  1. Say "what's in the README". The voice answers by itself.
  2. Say "run the tests". Your words arrive in Claude Code's chat as a turn of yours, and the voice says it's on it.
  3. While it works, say "stop". The turn stops, and the voice says "Stopped it."
  4. Ask for something again and wait: when the work is done, the voice tells you how it went, in its own words; the rest is in the chat.
  5. On a call the line reads "◉ On a call with Claude 1m (1: mute · 2: end · 3: window)", with the last exchange dim under it; press 2 to end, and the call's summary shows on the band for a while (it stays in Standby).

If /standby listen says Standby can't listen here, your terminal app was started from another Claude Code session: start Claude Code with env -u CLAUDE_CODE_CHILD_SESSION claude.

For developers: tools/lite_fake_app.py serves the same API with a stand-in meeting (no microphone), and claude plugin test in this folder runs the mod's tests.

Source 2 files
hooks/register.js 910 lines
1// Standby inside Claude Code. The Standby app does the hearing, speaking and remembering
2// (127.0.0.1, its own port); this module is what the person sees here, and the one way
3// in and out of this session: a meeting's transcript with the person's prompts (or, set to live, as
4// quiet notes as it goes), a call's hand-offs as turns. A summary stays in Standby: never sent here.
5import { said, jsonLines, minutes, noteLine, toolWords, joined, helpText, nameArgs, startNote, settingsText, setArgs } from './words.js'
6
7// A key drawn as a link (VS Code, the desktop): an address nothing opens, answered by the mod
8const KEY_LINK = 'https://standby.invalid/'
9// Standby's mark, as the app icon draws it small (mac/icons.py, wave_icon small): three white bars on the
10// indigo-to-violet rounded square. For VS Code and the desktop, one text line high.
11const LOGO = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 94 94"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">' +
12  '<stop offset="0" stop-color="#5046e5"/><stop offset="1" stop-color="#8b3fe8"/></linearGradient></defs>' +
13  '<rect width="94" height="94" rx="22" fill="url(#g)"/><g fill="#fff">' +
14  '<rect x="27.5" y="37" width="11" height="20" rx="5.5"/><rect x="41.5" y="28.25" width="11" height="37.5" rx="5.5"/>' +
15  '<rect x="55.5" y="34.5" width="11" height="25" rx="5.5"/></g></svg>'      // the pane: only the summary, once, after a meeting or a call (nothing streams here)
16const MARK = '#6d63ff'      // Standby's own colour, for a call's mark (a recording is red)
17const WARN = '#d29922'      // the band's word that something isn't working (the voice, Standby)
18// Claude Code started from inside another (its terminal app carries CLAUDE_CODE_CHILD_SESSION, which
19// a new window of that app inherits too) doesn't save its conversation, so Standby can't listen in it
20const UNSAVED = 'Standby can\u2019t listen here: this Claude Code doesn\u2019t save its conversation. ' +
21  'Quit it and start it with: env -u CLAUDE_CODE_CHILD_SESSION claude'
22// What an action says when it worked: a band press shows nothing then (the band itself changes)
23const LISTENING = { ask: 'Listening. Claude here gets the transcript with each of your prompts.', live: 'Listening. Claude here hears the meeting as it goes.' }
24const OK = new Set(['Standby\u2019s window is open.', LISTENING.ask, LISTENING.live, 'Stopped. The summary comes in a moment.',
25  'On a call. Talk; real work comes here.', 'Call ended.', 'Listening again.', 'Paused.', 'Muted.', 'Unmuted.', 'The call is in the mini window.'])
26const S = {
27  on: false,            // /standby turned it on for this session
28  port: 8765,
29  cc: '',               // this Claude Code session's id
30  st: null,             // the app's state: { app, can, live }
31  lines: [],            // the live meeting's lines, newest last
32  names: {},            // speaker -> the name the engine knows
33  pending: [],          // lines Claude hasn't had yet (before the next batch)
34  card: null,           // the ended meeting's or call's card: { session, title, points, gist }
35  turn: null,           // the session's running turn id, or null when idle
36  stream: null,         // the app's live stream (a spawned curl)
37  caption: '',          // the last line said, for the band
38  call: null,           // { session } while a call is live
39  fromCall: new Set(),  // the words of a call's hand-offs, to mark them in the chat
40  toasted: false,       // the one toast, on the first /standby
41  unsaved: false,       // this Claude Code doesn't save its conversation (started inside another): Listen can't work
42  saidTool: 0,          // when a call's voice was last told what the session is doing
43  heard: { you: '', said: '' },   // a call's turn as it goes (the captions)
44  said: null,           // the last finished exchange of a call, for the band's second line
45  holding: 0,           // inside a tool call of ours: a turn is running, whatever turn.start said
46  later: [],            // turns to submit once the running one ends (a hook can't wait on its own turn)
47  noted: new Set(),     // meetings this session was told have started (on ask)
48  incoming: null,       // { id, kind, expires }: Claude asks to start a call or listening, on the band until answered
49  starting: null,       // { kind, since, cancel? }: a call or listening starting, until it's live
50  ending: null,         // { kind, since }: ended, until its summary's row is up
51  flash: null,          // { mark, color, text, until }: a short line that then goes (declined, no answer, added)
52  callState: { phase: '', since: 0, working: false, workingSince: 0, notice: '', retrying: 0, waiting: false },   // the call's voice
53  unreach: 0,
54  confirm: null,        // End asked on the band: { what, question, yes, no, until }
55  keysNow: [],          // the terminal band's actions as last drawn: [[letter, what]], what a typed letter runs
56  hintAt: 0,            // when the "type a letter and press Enter" hint first showed (once per session)
57  keyUsed: false,       // a letter typed with Enter ran an action: the hint has done its job           // since when Standby hasn't answered while something was going on, or 0
58}
59
60// ---------------------------------------------------------------- the app
61
62// Whether this Standby has its compact window
63function canWindow() { return !!(S.st && S.st.can && S.st.can.window) }
64
65// Standby's compact window: the app opens it (or brings it to the front); nothing comes back here
66async function openWindow($) {
67  if (!S.on) await turnOn($)
68  if (!canWindow()) return 'Standby\u2019s window isn\u2019t in this version of Standby.'
69  const live = S.st && S.st.live
70  const r = await app($, '/api/window', { session: live ? live.session : null, cc: S.cc })
71  if (r.error) return r.error
72  // In front, every time: macOS (14 on) lets an app in the background raise itself only when the app in
73  // front hands it over, and the app in front is this one (the terminal, VS Code). Standby asking alone
74  // brought its window up the first time, then never.
75  await $.process.run(['open', '-a', 'Standby']).catch(() => {})
76  return 'Standby’s window is open.'
77}
78
79// The app's port and this launch's token, as it wrote them in ~/.standby (a new app may have moved, or
80// started again with a new token): every request carries the token
81async function where($) {
82  const home = (await $.env.get('HOME')) + '/.standby/'
83  const port = await $.fs.read(home + 'ui.port').catch(() => '')
84  if (/^\d+$/.test(String(port).trim())) S.port = Number(String(port).trim())
85  S.token = String(await $.fs.read(home + 'ui.token').catch(() => '')).trim()
86  S.header = home + 'ui.header'
87}
88
89async function app($, path, body, again) {
90  try {
91    const r = await $.http.fetch('http://127.0.0.1:' + S.port + path, body === undefined ? { headers: { 'x-standby-token': S.token || '' } } :
92      { method: 'POST', headers: { 'content-type': 'application/json', 'x-standby-token': S.token || '' }, body: JSON.stringify(body) })
93    if (r.status === 401 && !again) { await where($); return app($, path, body, true) }    // Standby started again: its new token
94    if (r.status === 404) return { error: 'This Standby doesn\u2019t have that yet: update it' }
95    return r.ok ? JSON.parse(r.text || '{}') : { error: 'Standby answered ' + r.status }
96  } catch (err) {
97    return { error: 'Standby isn’t running' }
98  }
99}
100
101async function refresh($) {
102  const st = await app($, '/api/lite/state?cc=' + encodeURIComponent(S.cc))
103  // it quit, or is too busy to answer, while something was going on: the band stays, says so, and comes back by itself
104  const going = !!(S.starting || S.ending || (S.st && S.st.live) || (S.card && S.card.waiting) || S.unreach)
105  S.unreach = st.error === 'Standby isn\u2019t running' && going ? S.unreach || Date.now() : 0
106  S.st = st.error ? { app: null, error: st.error } : Object.assign({}, st, { app: true })
107  if (S.st.live) S.lastLive = Object.assign({ seenAt: Date.now() / 1000 }, S.st.live)   // what the summary will be of
108  else if (!st.error && S.lastLive && !S.lastLive.carded && !S.ending && !(S.card && S.card.session === S.lastLive.session)) {
109    // it ended (the voice's goodbye, End in the window, a quiet while): Ending, until its summary's row is up
110    S.lastLive.carded = true
111    S.ending = { kind: S.lastLive.kind === 'call' ? 'call' : 'listen', since: Date.now() }
112    void cardOf($, S.lastLive.session)
113  }
114  $.ui.invalidate('ui.render')
115}
116
117// The app's live stream: meeting lines, renames, a call's captions and hand-offs. Runs while
118// /standby is on; leaving the loop ends curl.
119async function listen($) {
120  if (S.stream) return
121  const url = 'http://127.0.0.1:' + S.port + '/api/lite/stream?cc=' + encodeURIComponent(S.cc)
122  // the token from its file (-H @file), never on the command line, where ps would show it
123  S.stream = $.process.spawn({ argv: ['curl', '-sN', '--max-time', '0', '-H', '@' + (S.header || ''), url] })
124  let rest = ''
125  try {
126    for await (const chunk of S.stream) {
127      if (!S.on) break
128      if (chunk.stream !== 'stdout') continue
129      const got = jsonLines(rest, chunk.text)
130      rest = got.rest
131      for (const ev of got.events) await heard($, ev)
132      if (S.resync) break
133    }
134  } catch (err) {
135    // the app went away: the state poll says so, and /standby reconnects
136  }
137  S.stream = null
138  if (S.resync && S.on) {
139    S.resync = false
140    const live = S.st && S.st.live, last = S.lines.length ? S.lines[S.lines.length - 1].i : -1
141    if (live && live.kind === 'meeting') {
142      const got = await app($, '/api/lite/lines?session=' + encodeURIComponent(live.session) + '&after=' + last)
143      for (const l of got.lines || []) await heard($, Object.assign({ meet: 'line', session: live.session }, l))
144    }
145    void listen($)
146  } else if (S.on) {
147    // the stream ended (Standby quit or restarted): reconnect once it answers again, so a meeting's
148    // lines keep reaching Claude; the state poll keeps the band right meanwhile
149    void (async () => {
150      for (let i = 0; i < 120 && S.on && !S.stream; i++) {
151        await $.clock.sleep(i < 10 ? 1000 : 5000)
152        await refresh($).catch(() => {})
153        if (S.st && S.st.app) { void listen($); return }
154      }
155    })().catch(() => {})
156  }
157}
158
159async function heard($, ev) {
160  if (ev.resync) { S.resync = true; return }
161  if (ev.deleted) {
162    // the meeting or call was deleted in Standby: its summary card goes from the band too, and an
163    // Ending waiting for its card stops waiting
164    if (S.card && S.card.session === ev.session) S.card = null
165    if (S.lastLive && S.lastLive.session === ev.session) { S.lastLive.carded = true; S.ending = null }
166    $.ui.invalidate('ui.render')
167    return
168  }
169  if (ev.meet === 'line') {
170    S.lines.push(ev); S.pending.push(ev)
171    const tail = joined(S.lines.slice(-8)); S.caption = said(tail[tail.length - 1], S.names); S.captionOf = ev.session
172    if (S.lines.length > 2000) S.lines.splice(0, S.lines.length - 2000)
173    await batch($, ev.session)
174  } else if (ev.meet === 'rename') {
175    Object.assign(S.names, ev.renamed || {})
176  } else if (ev.meet === 'state') {
177    await refresh($)
178    if (ev.ended) void cardOf($, ev.session, ev.note)
179  } else if (ev.meet === 'card' || ev.call === 'card') {
180    // the engine made the card: shown here once, as the engine has it; it stays in Standby
181    if (!S.card || S.card.session !== ev.session) void cardOf($, ev.session)
182  } else if (ev.call === 'state') {
183    const cs = S.callState, now = Date.now()
184    if (ev.phase !== cs.phase) { cs.phase = ev.phase; cs.since = now }
185    if (!!ev.working !== cs.working) { cs.working = !!ev.working; cs.workingSince = now }
186    cs.notice = ev.notice || ''
187    cs.waiting = !!ev.waiting
188    cs.retrying = ev.retrying_since ? ev.retrying_since * 1000 : 0
189    if (S.starting && S.starting.voice && (ev.phase === 'speaking' || ev.phase === 'ended')) S.starting = null
190    if (ev.phase === 'ended') { S.call = null; S.caption = ''; S.said = null; S.last = null; S.heard = { you: '', said: '' }; void cardOf($, ev.session) }
191    // a turn done (the voice back to listening): the band's second line takes the exchange, once
192    else if (ev.phase === 'listening' && (S.heard.you || S.heard.said)) { S.said = Object.assign({}, S.heard); S.heard = { you: '', said: '' } }
193    await refresh($)
194  } else if (ev.call === 'caption') {
195    // the voice ready: its greeting heard, or they spoke first; the call shows from here
196    if (S.starting && S.starting.voice && (ev.said || (ev.you && !/^\[CALL START\]/.test(String(ev.you))))) S.starting = null
197    S.caption = ev.said || ev.you || S.caption
198    if (ev.you) S.heard.you = String(ev.you).replace(/^\[CALL START\]\s*/, '')     // the greeting's hidden marker: never shown
199    if (ev.said) S.heard.said = ev.said
200    // the band's second line: the latest words, whoever said them, as soon as they're heard
201    if (ev.said || ev.you) { S.last = ev.said ? { voice: true, text: ev.said } : { voice: false, text: ev.you }; $.ui.invalidate('ui.render') }
202  } else if (ev.call === 'handoff') {
203    // The person's own words, said in a call: real work for this session, never stopping what it's on
204    // (Claude Code takes it into a running turn). Standby hears whether it really got here
205    S.fromCall.add(ev.text.trim())
206    void handOn($, ev)
207  } else if (ev.call === 'summary') {
208    // After a call, the user chose to add it here: what it discussed that this session didn't know yet.
209    // A quiet note, read with the next prompt: no turn of its own
210    await note($, ev.text)
211  } else if (ev.call === 'abort') {
212    if (S.turn) await $.turn.abort({ turnId: S.turn })
213  }
214  $.ui.invalidate('ui.render')
215}
216
217// The engine decides when a batch is due (its push policy); each one is a quiet note here
218async function batch($, session, all) {
219  const b = await app($, '/api/lite/batch?session=' + encodeURIComponent(session) + (all ? '&all=1' : ''))
220  if (!b.text) return ''
221  if (!all) await note($, b.text)
222  await app($, '/api/lite/batch/sent', { session, through: b.through })
223  S.pending = S.pending.filter(l => l.i > b.through)
224  return b.text
225}
226
227// The ended meeting's card, once the engine has made it
228async function cardOf($, session, note) {
229  // at once: what ended, "Summarizing…" where its points will be (the summary takes a few seconds)
230  if (!S.card || S.card.session !== session) {
231    S.ending = null                                         // the summary's row takes over from Ending
232    const l = S.lastLive && S.lastLive.session === session ? S.lastLive : {}
233    const secs = l.recorded_s != null ? l.recorded_s : l.started ? (l.seenAt || Date.now() / 1000) - l.started : null
234    S.card = { session, kind: l.kind || null, title: l.title || (l.kind === 'call' ? 'Call' : 'the meeting'), duration_s: secs, waiting: true, shownAt: Date.now(), note: note || null }
235    $.ui.invalidate('ui.render')
236  } else {
237    if (note && !S.card.note) { S.card.note = note; $.ui.invalidate('ui.render') }     // its word came after the card
238    if (!S.card.waiting) return                             // this one's summary is here already
239  }
240  try {                                                     // ends quietly with the session (the mod unloaded)
241  for (let i = 0; i < 60; i++) {
242    const c = await app($, '/api/lite/card?session=' + encodeURIComponent(session))
243    if (c.title || c.points || c.gist) {
244      const said = S.card && S.card.session === session ? S.card.note : null, kind = S.card && S.card.session === session ? S.card.kind : null
245      S.card = Object.assign({ session, shownAt: Date.now(), kind }, c, { note: c.note || said || null }); S.lines = []; S.pending = []
246      // shown on the band for a while, then gone (or at the next prompt): everything is kept, nothing to choose
247      void $.clock.sleep(SUMMARY_MS + 100).then(() => $.ui.invalidate('ui.render')).catch(() => {})
248      $.ui.invalidate('ui.render')
249      return
250    }
251    await $.clock.sleep(2000)
252  }
253  } catch (err) {}
254}
255
256// A call's hand-off, into the session now, and word back of whether it entered: the voice says it
257// went only once it has
258async function handOn($, ev) {
259  // the session busy (a turn running): Claude Code queues it and starts it when that turn ends; said now,
260  // so the voice knows it waits rather than went missing
261  if (S.turn || S.holding) await app($, '/api/lite/progress', { session: ev.session, id: ev.id || '', kind: 'queued', text: '' })
262  let r
263  try { r = await $.prompt.submit({ text: ev.text, asUser: true }) } catch (err) { r = { drop: String((err && err.message) || err) } }
264  const got = !!r && !r.drop
265  await app($, '/api/lite/progress', { session: ev.session, id: ev.id || '', kind: got ? 'received' : 'undelivered', text: got ? '' : String((r && r.drop) || '') })
266}
267
268// A turn Standby starts: now if the session is idle, else once the running turn ends
269function submit($, text, asUser) {
270  if (S.turn || S.holding) { S.later.push({ text, asUser: !!asUser }); return }
271  $.prompt.submit(asUser ? { text, asUser: true } : { text })
272}
273
274// A user-role note Claude reads and the person doesn't see as typed: no turn of its own
275async function note($, text) {
276  await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
277}
278
279// ---------------------------------------------------------------- the actions (buttons, /standby, tools)
280
281async function turnOn($, quiet) {
282  S.on = true
283  S.cc = await $.session.id()
284  await ensureApp($, quiet)
285  void listen($)
286  return S.st.app ? 'Standby is on for this session.' : 'Standby isn’t running, and it didn’t open.'
287}
288
289// Standby the app, running: opened in the background (no window) when it's asked for, never just
290// because Claude Code started (quiet). Its port is read afresh, as a new app may have moved.
291async function ensureApp($, quiet) {
292  await where($)
293  await refresh($)
294  if (!S.st.app && !quiet) {
295    await $.process.run(['open', '-g', '-a', 'Standby'])
296    for (let i = 0; i < 30; i++) {             // up to about 15 s while it starts
297      await where($)
298      await refresh($)
299      if (S.st.app) break
300      // waited in a $ call: a hook's own time (10 s) would run out on clock sleeps while the app starts
301      await $.process.run(['/bin/sleep', '0.5']).catch(() => {})
302    }
303    if (S.st.app) void listen($)
304  }
305  return !!S.st.app
306}
307
308// how: "ask" or "live", else the meeting setting in Standby's Settings
309async function startListening($, how) {
310  if (S.unsaved) return UNSAVED
311  if (!(S.st && S.st.live && S.st.live.kind === 'meeting')) startingNow($, 'listen')
312  if (!S.on) await turnOn($)
313  else if (!(await ensureApp($))) return startFailed($, 'listen', 'Standby isn\u2019t running, and it didn\u2019t open.')
314  void listen($)
315  const r = await app($, '/api/lite/listen', { cc: S.cc, cwd: await $.session.cwd(), ...(how ? { pass: how } : {}) })
316  if (r.error) return startFailed($, 'listen', r.error)
317  const cancelled = S.starting && S.starting.cancel
318  S.starting = null
319  S.card = null; S.lines = []; S.pending = []
320  if (S.captionOf && r.session && S.captionOf !== r.session) S.caption = ''   // never an earlier meeting's words under this one
321  await refresh($)
322  if (cancelled) { await stopListening($); return 'Stopped.' }      // End pressed while it was starting
323  const live = S.st && S.st.live, pass = r.pass || (live && live.pass) || 'live'
324  // on ask, one line so Claude knows there is a meeting to ask about (its transcript comes later)
325  if (pass === 'ask' && !S.noted.has(r.session)) { S.noted.add(r.session); await note($, startNote(live && live.title)) }
326  return LISTENING[pass] || LISTENING.live
327}
328
329async function stopListening($) {
330  const live = S.st && S.st.live
331  if (!live || live.kind !== 'meeting') return 'Nothing is recording.'
332  // set to live, what was said since the last batch goes too; on ask, it waits to be asked
333  if (live.pass !== 'ask') {
334    const rest = await batch($, live.session, true)
335    if (rest) await note($, rest)
336  }
337  S.ending = { kind: 'listen', since: Date.now() }
338  $.ui.invalidate('ui.render')
339  const r = await app($, '/api/lite/stop', { session: live.session })
340  if (r.error) S.ending = null
341  await refresh($)
342  return r.error || 'Stopped. The summary comes in a moment.'
343}
344
345async function pause($) {
346  const live = S.st && S.st.live
347  if (!live) return 'Nothing is live.'
348  const r = await app($, live.paused ? '/api/lite/resume' : '/api/lite/pause', { session: live.session })
349  await refresh($)
350  return r.error || (live.paused ? 'Listening again.' : 'Paused.')
351}
352
353// The call into the mini window in the corner, as the window's Mini window button: calls only
354async function toOrb($) {
355  const live = S.st && S.st.live
356  if (!live || live.kind !== 'call') return 'No call is live.'
357  const r = await app($, '/api/lite/orb', { cc: S.cc })
358  return r.error || 'The call is in the mini window.'
359}
360
361async function mute($) {
362  const live = S.st && S.st.live
363  if (!live || live.kind !== 'call') return 'No call is live.'
364  const r = await app($, '/api/lite/mute', { session: live.session, muted: !live.muted })
365  await refresh($)
366  return r.error || (live.muted ? 'Unmuted.' : 'Muted.')
367}
368
369// /standby name Speaker 1 Nadia: the speaker as the meeting labels them, then the name
370async function nameSpeaker($, args) {
371  const live = S.st && S.st.live
372  const session = live && live.kind === 'meeting' ? live.session : S.card ? S.card.session : null
373  if (!session) return 'No meeting to name anyone in.'
374  let labels = speakerLabels()
375  if (!labels.length) {
376    const got = await app($, '/api/lite/lines?session=' + encodeURIComponent(session) + '&after=0')
377    for (const l of got.lines || []) S.lines.push(l)
378    Object.assign(S.names, got.renamed || {})
379    labels = speakerLabels()
380  }
381  const m = nameArgs(args, labels)
382  if (!m) return labels.length ? 'Name whom? /standby name ' + labels[0].label + ' Nadia (' + labels.map(x => x.label).join(', ') + ')'
383    : 'Nobody has spoken yet.'
384  const r = await app($, '/api/speaker', { id: session, spk: m.speaker, name: m.name })
385  if (r.error) return r.error
386  S.names[m.speaker] = m.name
387  $.ui.invalidate('ui.render')
388  return m.label + ' is ' + m.name + ' now. Standby will know their voice.'
389}
390// who has spoken, as the meeting labels them now: [{ speaker, label }]
391function speakerLabels() {
392  const seen = new Map()
393  for (const l of S.lines) if (l.speaker != null && !seen.has(String(l.speaker))) seen.set(String(l.speaker), S.names[l.speaker] || l.name || 'Speaker ' + l.speaker)
394  return [...seen].map(([speaker, label]) => ({ speaker, label }))
395}
396
397// A band button: the action, and its answer as a toast only when it didn't happen
398// What ends something (a call, listening) asks once, on the band itself (never Claude Code's own question
399// dialog): the row becomes the question, with its two answers, for CONFIRM_MS, then it's as it was
400const ENDS = { endCall: ['End the call?', 'End', 'Keep talking'], cancelStart: ['End the call?', 'End', 'Keep talking'],
401  stopListening: ['Stop listening?', 'Stop', 'Keep listening'] }
402const CONFIRM_MS = 10000
403const DIV = '|'                         // in a band's actions: a quiet divider, the call's own from the windows
404const HINT_MS = 60000                   // how long the "type a letter and press Enter" hint shows, the first time
405
406async function press($, what, sure) {
407  if (ENDS[what] && !sure) {
408    const [question, yes, no] = what === 'cancelStart' && S.starting && S.starting.kind !== 'call' ? ENDS.stopListening : ENDS[what]
409    const c = S.confirm = { what, question, yes, no, until: Date.now() + CONFIRM_MS }
410    void $.clock.sleep(CONFIRM_MS).then(() => { if (S.confirm === c) S.confirm = null; $.ui.invalidate('ui.render') }).catch(() => {})
411    $.ui.invalidate('ui.render')
412    return 'asked'
413  }
414  if (what === 'confirmYes' || what === 'confirmNo') {
415    const c = S.confirm
416    S.confirm = null
417    $.ui.invalidate('ui.render')
418    return c && what === 'confirmYes' ? press($, c.what, true) : 'closed'
419  }
420  const r = what === 'startListening' ? await startListening($) : what === 'startCall' ? await startCall($)
421    : what === 'pause' ? await pause($) : what === 'stopListening' ? await stopListening($)
422    : what === 'mute' ? await mute($) : what === 'openWindow' ? await openWindow($) : what === 'toOrb' ? await toOrb($)
423    : what === 'endCall' ? await endCall($)
424    : what === 'cancelStart' ? (S.starting && S.starting.voice ? (S.starting = null, void endCall($))     // the call is up: end it
425      : S.starting && (S.starting.cancel = true, S.ending = { kind: S.starting.kind, since: Date.now() }),
426      $.ui.invalidate('ui.render'), 'closed')
427    : what === 'closeSummary' ? (S.card && (S.card.seen = true), $.ui.invalidate('ui.render'), 'closed')
428    : what === 'addSummary' ? await addSummary($)
429    : what === 'incomingAccept' ? await answerIncoming($, 'accept')
430    : what === 'incomingDecline' ? await answerIncoming($, 'decline')
431    : what === 'incomingAlways' ? await answerIncoming($, 'always')
432    : 'No such Standby action'
433
434  if (!OK.has(r) && r !== 'closed' && r !== 'asked') $.ui.toast(r)
435  return r
436}
437
438async function startCall($) {
439  startingNow($, 'call')
440  if (!S.on) await turnOn($)
441  else if (!(await ensureApp($))) return startFailed($, 'call', 'Standby isn\u2019t running, and it didn\u2019t open.')
442  void listen($)
443  const r = await app($, '/api/lite/call', { cc: S.cc, cwd: await $.session.cwd() })
444  if (r.error) return startFailed($, 'call', r.error)
445  const cancelled = S.starting && S.starting.cancel
446  const since = (S.starting && S.starting.since) || Date.now()
447  S.starting = null
448  if (r.already) { $.ui.invalidate('ui.render'); return 'You\u2019re already on this call. To end it, say goodbye or use /standby end.' }
449  // still Connecting until the voice is ready: its greeting starts, or they speak first (or a while passes)
450  if (!cancelled) S.starting = { kind: 'call', since, voice: true, until: Date.now() + VOICE_READY_MS }
451  S.call = { session: r.session }
452  S.caption = ''
453  S.callState = { phase: 'listening', since: Date.now(), working: false, workingSince: 0, notice: '', retrying: 0, waiting: false }
454  await refresh($)
455  if (cancelled) { await endCall($); return 'Call ended.' }            // End pressed while it was connecting
456  return 'On a call. Talk; real work comes here.'
457}
458
459const VOICE_READY_MS = 45000            // the most Connecting waits for the voice's greeting before showing the call
460
461// The band says what's happening while a call or listening starts, from the click on: connecting, with
462// moving dots and the seconds, until it's live; or why it couldn't, for a few seconds
463function startingNow($, kind) {
464  S.starting = kind ? { kind, since: Date.now() } : null
465  $.ui.invalidate('ui.render')
466}
467function startFailed($, kind, why) {
468  S.starting = null
469  flash($, '\u25c9', null, (kind === 'call' ? 'Couldn\u2019t start the call \u00b7 ' : 'Couldn\u2019t start listening \u00b7 ') + why, 6000)
470  return why
471}
472// A short line on the band that then goes: declined, no answer, added, a start that failed
473function flash($, mark, color, text, ms) {
474  S.flash = { mark, color, text, until: Date.now() + (ms || 3000) }
475  $.ui.invalidate('ui.render')
476  void $.clock.sleep((ms || 3000) + 100).then(() => $.ui.invalidate('ui.render')).catch(() => {})
477}
478
479// Add to Claude Code: Standby writes what the call discussed that this session doesn't know yet and puts it
480// in as a quiet note (the app's /api/lite/card/add, a few seconds). Done, or nothing new: the card goes.
481// Not ready, or it couldn't: the card stays, to try again.
482async function addSummary($) {
483  const c = S.card; if (!c || c.adding) return 'closed'
484  c.adding = Date.now(); $.ui.invalidate('ui.render')
485  let r; try { r = await app($, '/api/lite/card/add', { session: c.session }) } catch (e) { r = { error: 'Standby isn\u2019t answering' } }
486  c.adding = false
487  const nothing = r && r.error === 'There was nothing new for Claude Code in that call'
488  if (r && (r.ok || nothing)) c.seen = true
489  if (r && r.ok) flash($, '\u2713', 'green', 'Added to Claude Code')
490  $.ui.invalidate('ui.render')
491  return r && r.ok ? 'Added to Claude Code' : (r && r.error) || 'Couldn\u2019t add it: try again'
492}
493
494async function endCall($) {
495  const live = S.st && S.st.live
496  if (!live || live.kind !== 'call') return 'No call is live.'
497  S.ending = { kind: 'call', since: Date.now() }
498  $.ui.invalidate('ui.render')
499  const r = await app($, '/api/lite/call/end', { session: live.session })
500  if (r.error) S.ending = null
501  S.call = null
502  await refresh($)
503  return r.error || 'Call ended.'
504}
505
506// After a meeting or a call its summary is on the band this long (or until the next prompt), then gone
507const SUMMARY_MS = 30000
508const SUMMARIZING_MS = 120000          // while the summary is on its way (the engine gives up after about that)
509// A call's summary offers Add to Claude Code or Forget (the user, 2026-10-21): it stays until one is chosen
510const choosing = () => !!(S.card && S.card.kind === 'call' && !S.card.waiting && !S.card.seen)
511const summaryShown = () => !!(S.card && S.card.shownAt && !S.card.seen && (choosing() || Date.now() - S.card.shownAt < (S.card.waiting ? SUMMARIZING_MS : SUMMARY_MS)))
512
513// Standby's settings, by name: listed (no name), or one changed; the app checks the value
514async function settings($, name, value) {
515  if (!S.on) await turnOn($, true)
516  if (!name) {
517    const r = await app($, '/api/settings/named')
518    return r.error || settingsText(r.settings)
519  }
520  if (value === undefined || value === null || String(value).trim() === '') return 'Say what to set ' + name + ' to: /standby settings lists the choices.'
521  const r = await app($, '/api/settings', { name, value: String(value) })
522  if (r.error) return r.error
523  // The setting is for new meetings; a meeting already running here switches with it
524  const live = S.st && S.st.live
525  if (r.name === 'transcript' && live && live.kind === 'meeting') {
526    const m = await app($, '/api/lite/pass', { session: live.session, pass: r.value })
527    if (!m.error) return 'transcript is now ' + r.value + ', for this meeting too.'
528  }
529  return r.name + ' is now ' + r.value + '.'
530}
531
532// A meeting's transcript for Claude, when it asks (the meeting tool): this session's latest
533// meeting, live or ended, all of it
534async function meetingText($) {
535  if (!S.on) await turnOn($, true)
536  const r = await app($, '/api/lite/transcript?cc=' + encodeURIComponent(S.cc))
537  return r.error || r.text
538}
539
540// Claude starting a call or listening on its own (its tools): it rings on the band (Accept, Decline,
541// Always accept), and nothing starts without the user's Accept, or their Always accept from before: the
542// mic never opens on Claude's word alone. Typed /standby call and /standby
543// listen, and the band's buttons, are the user's own choice: not asked.
544const ASK_FIRST = {
545  call: ['call', 'The user declined the call. Nothing was started.'],
546  listen: ['meeting', 'The user declined listening. Nothing was started.'],
547}
548const ASK_WAIT_S = 31                     // the app declines it unanswered after 30 s
549// Claude asking for a call or listening: { go: true } to start it now (nothing to ask: already on), { accepted:
550// true } once they said yes (it starts on the band, the tool answers at once), or { result } for Claude
551async function askToStart($, act) {
552  const [kind] = ASK_FIRST[act]
553  const live = S.st && S.st.live
554  if (live && live.kind === kind) return { go: true }   // already on: nothing new starts, nothing to ask
555  if (act === 'listen' && S.unsaved) return { result: UNSAVED }   // it couldn't listen here anyway: never asked for nothing
556  if (!S.on) await turnOn($)
557  else await ensureApp($)
558  const r = await app($, '/api/lite/incoming', { cc: S.cc, kind: act })
559  if (r.error) return { result: r.error }
560  if (r.answer === 'accept') return { accepted: true }
561  if (!r.id) return { result: 'Declined' }
562  // On the band until they answer. The wait is one request the app holds (a hook's own time is short, and a
563  // wait inside a $ call isn't counted): never a sleep loop, which would outlast the hook's budget.
564  S.incoming = { id: r.id, kind: act, expires: Date.now() + 30000 }
565  $.ui.invalidate('ui.render')
566  let a
567  try {
568    a = await app($, '/api/lite/incoming?id=' + encodeURIComponent(r.id) + '&wait=' + ASK_WAIT_S)
569  } finally {
570    if (S.incoming && S.incoming.id === r.id) S.incoming = null
571    $.ui.invalidate('ui.render')
572  }
573  if (a.answer === 'accept') return { accepted: true }
574  const call = act === 'call'
575  if (a.expired) {
576    flash($, '\u25c9', null, call ? 'No answer \u00b7 the call wasn\u2019t started' : 'No answer \u00b7 listening wasn\u2019t started')
577    return { result: call ? 'No answer: the call wasn\u2019t started' : 'No answer: listening wasn\u2019t started' }
578  }
579  flash($, '\u25c9', null, call ? 'Call declined' : 'Listening declined')
580  return { result: 'Declined' }
581}
582
583// The band's answer to Claude's request: Accept, Decline or Always accept
584async function answerIncoming($, answer) {
585  const inc = S.incoming
586  if (!inc) return 'closed'
587  S.incoming = null
588  if (answer !== 'decline') S.starting = { kind: inc.kind === 'call' ? 'call' : 'listen', since: Date.now() }     // connecting, at once
589  $.ui.invalidate('ui.render')
590  const r = await app($, '/api/lite/incoming/answer', { id: inc.id, answer })
591  return r.error || 'closed'
592}
593
594// Anything on the band whose dots or seconds move
595function moving() {
596  const cs = S.callState, live = S.st && S.st.live
597  return !!(S.incoming || S.starting || S.ending || S.unreach || S.flash || (S.card && (S.card.waiting || S.card.adding))
598    || (live && live.kind === 'call' && (cs.phase === 'thinking' || cs.working || cs.retrying)))
599}
600const DOTS = ['.', '. .', '. . .']
601
602// ---------------------------------------------------------------- the hooks
603
604// The band itself (above): what's on it now, one stage at a time
605async function band($, e, next) {
606  const live = S.st && S.st.app && S.st.live
607  const waiting = !live && summaryShown()
608  if (S.flash && Date.now() > S.flash.until) S.flash = null
609  if (S.starting && S.starting.voice && Date.now() > S.starting.until) S.starting = null
610  if (!S.on || (!live && !waiting && !S.incoming && !S.starting && !S.ending && !S.flash && !S.unreach)) return next(e)
611  const { Box, Text, Button, Markdown, Svg } = $.ui.resolve(e)
612  const term = e.surface === 'terminal'
613  const mark = term ? [] : [Svg({ source: LOGO, alt: 'Standby', width: 16, height: 16 })]
614  const fixed = (text, props) => Box({ flexShrink: 0, children: [Text(Object.assign({ children: text }, props || {}))] })
615  // text that takes what room is left and is cut with … rather than wrapped
616  const fit = (text, props) => Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, overflow: 'hidden',
617    children: [Text(Object.assign({ wrap: 'truncate-end', children: text }, props || {}))] })
618  const line = kids => Box({ flexDirection: 'row', flexWrap: 'nowrap', alignItems: 'center', columnGap: 1, children: kids })
619  // the terminal's keys: "p pause · e end | o full window", dim, the letters in the accent
620  // the terminal's actions: "p pause · e end · o window", each letter in Standby's colour and the word bold,
621  // only the separators dim. Each is a letter typed alone, then Enter (or Enter on it with the band focused)
622  // a list's groups, the call's own actions then the windows: DIV between them, dropped where nothing follows
623  const groups = list => list.filter((x, i) => x !== DIV || (i > 0 && i < list.length - 1 && list[i - 1] !== DIV))
624  const digits = list => {
625    list = groups(list)
626    for (const k of list) if (k !== DIV) S.keysNow.push([k[1], k[3]])
627    return Box({ flexDirection: 'row', flexWrap: 'nowrap', flexShrink: 0, children:
628      list.flatMap((k, i) => k === DIV ? [Text({ dimColor: true, children: ' | ' })]
629        : [...(i && list[i - 1] !== DIV ? [Text({ dimColor: true, children: ' \u00b7 ' })] : []),
630          Button({ key: k[0], label: k[2], plain: true, onPress: async () => { await press($, k[3]) },
631            children: [Text({ color: k[5] ? 'red' : MARK, weight: 'bold', children: k[1] }), Text({ weight: 'bold', children: ' ' + k[2] }),
632              ...(k[4] ? [Text({ key: k[0] + '-dot', color: 'red', children: ' \u2022' })] : [])] })]) })
633  }
634  // VS Code and the desktop draw a Button as a box: a quiet link instead, which the mod answers itself
635  // a red dot beside one: something waits behind it (Resume, on a paused call)
636  // one line, never wrapped: the link and a waiting dot on it, as the row's other links
637  const link = (key, word, what, quiet, dot) => Box({ flexDirection: 'row', flexWrap: 'nowrap', flexShrink: 0, alignItems: 'center', children: [
638    Markdown({ key, dimColor: !!quiet,
639    text: '[' + word + '](' + KEY_LINK + key + ')', pressableLinks: [KEY_LINK + key], onLinkPress: async () => { await press($, what) } }),
640    ...(dot ? [Text({ key: key + '-dot', color: 'red', children: ' \u2022' })] : [])] })
641  // the same actions: letter keys in the terminal, quiet links elsewhere (links on the top line only)
642  const keyset = (keys, words) => term ? [digits(keys)]
643    : [Box({ flexDirection: 'row', flexWrap: 'nowrap', flexShrink: 0, columnGap: 1, children: groups(words).map(w => w === DIV ? Text({ dimColor: true, children: '|' }) : link(...w)) })]
644  const now = Date.now(), secsOf = t => Math.max(0, Math.floor((now - t) / 1000))
645  // a slow step: its plain words and the dots moving, so it's plainly not stuck
646  const slow = (text, since, props) => [fit(text + ' ' + DOTS[secsOf(since) % 3], props)]
647  // the dim line under the top one: what's happening now
648  const under = kids => [Box({ flexDirection: 'row', flexWrap: 'nowrap', columnGap: 1, children: [...(term ? [] : [Box({ width: 1, flexShrink: 0 })]), ...kids] })]
649  const below = (text, props) => text ? under([fit(text, Object.assign({ dimColor: true }, props || {}))]) : []
650  const belowSlow = (text, since, props) => under(slow(text, since, Object.assign({ dimColor: true }, props || {})))
651  const win = canWindow()
652
653  // End, asked: the row is the question and its two answers, for a while (then it's as it was), while
654  // there's still something to end
655  if (S.confirm && (Date.now() > S.confirm.until || (!live && !S.starting))) S.confirm = null
656  if (S.confirm) {
657    const c = S.confirm, call = c.question === ENDS.endCall[0]
658    return line([...mark, fixed(call ? '\u25c9' : '\u25cf', { color: 'red' }), fit(c.question, { weight: 'semibold' }),
659      ...keyset([['confirmYes', 'e', c.yes.toLowerCase(), 'confirmYes', false, true], ['confirmNo', 'k', c.no.toLowerCase(), 'confirmNo']],
660        [['confirmYes', c.yes, 'confirmYes', false], ['confirmNo', c.no, 'confirmNo', true]])])
661  }
662  // Claude asks: it rings, with the time left, and nothing starts without the click
663  if (S.incoming) {
664    const call = S.incoming.kind === 'call', left = Math.max(0, Math.ceil((S.incoming.expires - now) / 1000))
665    return line([...mark, fixed('◉', { color: MARK }), fit(call ? 'Incoming call' : 'Listen to your meeting?', { weight: 'semibold' }),
666      fixed(left + 's', { dimColor: true }),
667      ...keyset([['accept', 'a', call ? 'accept' : 'listen', 'incomingAccept'], ['decline', 'd', 'decline', 'incomingDecline'], ['always', 'y', 'always accept', 'incomingAlways']],
668        [['accept', call ? 'Accept' : 'Listen', 'incomingAccept'], ['decline', 'Decline', 'incomingDecline', true], ['always', 'Always accept', 'incomingAlways', true]])])
669  }
670  // a short line that then goes: declined, no answer, added, a start that failed
671  if (S.flash) return line([...mark, fixed(S.flash.mark, S.flash.color ? { color: S.flash.color } : { dimColor: true }),
672    fit(S.flash.text, S.flash.color ? {} : { dimColor: true })])
673  // connecting (or starting to listen), until it's live; End stops it
674  if (S.starting && !S.ending) {
675    const call = S.starting.kind === 'call'
676    return line([...mark, fixed(call ? '◉' : '●', { color: call ? MARK : 'red' }), ...slow(call ? 'Connecting' : 'Starting to listen', S.starting.since),
677      ...keyset([['end', 'e', 'end', 'cancelStart']], [['end', 'End', 'cancelStart', true]])])
678  }
679  // Standby not answering (it quit, or the Mac is too busy to): the band stays, and comes back by itself
680  if (S.unreach) return line([...mark, fixed('◉', { color: WARN }), ...slow('Standby isn’t answering · trying again', S.unreach)])
681  // ending, until the summary's row is up
682  if (S.ending) return line([...mark, ...(term ? [fixed('⏺', { dimColor: true })] : []), ...slow(S.ending.kind === 'call' ? 'Ending the call' : 'Ending', S.ending.since, { dimColor: true })])
683  if (!live) {
684    // the summary, after a meeting or a call: a meeting's shown for a while; a call's until it's added or forgotten
685    const c = S.card, points = c.waiting ? null : c.points && c.points.length ? c.points.slice(0, 3) : [c.gist || 'Nothing was decided.']
686    // a meeting's: a quiet "close" (the terminal's 1); a call's: Add to Claude Code or Forget, until one is chosen
687    const close = choosing()
688      ? (c.adding ? slow('Adding to Claude Code', c.adding, { dimColor: true })
689        : keyset([['add', 'a', 'add to Claude Code', 'addSummary'], ['forget', 'f', 'forget', 'closeSummary']],
690          [['add', 'Add to Claude Code', 'addSummary', true], ['forget', 'Forget', 'closeSummary', true]]))
691      : keyset([['close', 'c', 'close', 'closeSummary']], [['close', 'Close', 'closeSummary', true]])
692    const rows = [line([...mark, ...(term ? [fixed('⏺')] : []), fit('Summary · ' + (c.title || 'the meeting'), { weight: 'semibold' }), ...(c.duration_s != null ? [fixed(minutes(c.duration_s, !term), { dimColor: true })] : []), ...close])]
693    // it stopped itself ("Stopped after 15 minutes of silence"): said once, dim, under its name
694    if (c.note) rows.push(Box({ flexDirection: 'row', flexWrap: 'nowrap', children: [
695      fixed(term ? '     ' : '   ', { dimColor: true }), fit(c.note, { dimColor: true })] }))
696    if (!points) rows.push(Box({ flexDirection: 'row', flexWrap: 'nowrap', columnGap: 1, children: [
697      fixed(term ? '  ⎿ ' : '  ', { dimColor: true }), ...slow('Summarizing', c.shownAt, { dimColor: true })] }))
698    else points.forEach((pt, i) => rows.push(Box({ flexDirection: 'row', flexWrap: 'nowrap', children: [
699      fixed(term ? (i ? '     ' : '  ⎿  ') : '   · ', { dimColor: true }), fit(pt)] })))
700    return Box({ flexDirection: 'column', rowGap: 0, children: rows })
701  }
702  const time = fixed(minutes(live.recorded_s != null ? live.recorded_s : Date.now() / 1000 - (live.started || Date.now() / 1000), !term), { dimColor: true })
703  // the call's own actions first, then a quiet "|", then the windows
704  const winKey = () => (win ? [['window', 'o', 'full window', 'openWindow']] : [])
705  const winWord = win ? [['window', 'Full window', 'openWindow', true]] : []
706  if (live.kind === 'meeting') {
707    const title = live.title || 'the meeting'
708    const keys = keyset([['pause', 'p', live.paused ? 'resume' : 'pause', 'pause'], ['stop', 'e', 'end', 'stopListening'], DIV, ...winKey()],
709      [['pause', live.paused ? 'Resume' : 'Pause', 'pause', true], ['stop', 'End', 'stopListening', true], DIV, ...winWord])
710    const top = live.paused
711      ? line([...mark, fixed('‖'), fit('Paused · ' + title), time, ...keys])
712      : line([...mark, fixed('●', { color: 'red' }), fit('Listening · ' + title), time, ...keys])
713    return Box({ flexDirection: 'column', children: [top, ...below(S.caption)] })
714  }
715  // on a call: the top line says where the call is, the dim one under it what's happening now
716  const cs = S.callState, failed = !!cs.notice, paused = !!live.muted || cs.phase === 'muted'
717  // paused, with what came back held for Resume: a red dot on Resume, and the voice says it on Resume
718  const held = paused && (cs.waiting || !!live.waiting)
719  // the call into the mini window in the corner (m), where Standby has it: the first of the windows
720  const orbKey = S.st && S.st.can && S.st.can.orb ? [['mini', 'm', 'mini window', 'toOrb']] : []
721  const orbWord = orbKey.length ? [['mini', 'Mini window', 'toOrb', true]] : []
722  const keys = failed
723    ? keyset([['end', 'e', 'end', 'endCall'], DIV, ...orbKey, ...winKey()], [['end', 'End', 'endCall', true], DIV, ...orbWord, ...winWord])
724    : keyset([['mute', 'p', paused ? 'resume' : 'pause', 'mute', held], ['end', 'e', 'end', 'endCall'], DIV, ...orbKey, ...winKey()],
725      [['mute', paused ? 'Resume' : 'Pause', 'mute', true, held], ['end', 'End', 'endCall', true], DIV, ...orbWord, ...winWord])
726  const top = paused
727    ? line([...mark, fixed('‖'), fit('Paused · on a call with Claude'), time, ...keys])
728    : line([...mark, fixed('◉', { color: MARK }), fit('On a call with Claude'), time, ...keys])
729  // the voice's words after Claude Code's own ✻, the user's plain
730  const said = S.last ? (S.last.voice ? '✻ ' : '') + S.last.text : ''
731  const sub = cs.retrying ? belowSlow('The voice isn’t answering. Trying again', cs.retrying, { dimColor: false, color: WARN })
732    : failed ? below('The voice stopped. End the call and start it again', { dimColor: false, color: WARN })
733    : held ? below('Paused. Something’s waiting: resume to hear it')
734    : paused ? below('Paused. Resume when you’re ready to talk')
735    : cs.phase === 'speaking' ? below(said || '✻')
736    : cs.phase === 'thinking' ? belowSlow('✻ Thinking', cs.since)
737    : cs.working ? belowSlow('✻ Working on it', cs.workingSince)
738    : cs.phase === 'listening' ? below(S.heard.you || 'Listening')
739    : below(said || 'Listening')
740  return Box({ flexDirection: 'column', children: [top, ...sub] })
741}
742
743export function register(on) {
744  on('session.start', async ($, e, next) => {
745    const started = await next(e)
746    // Each piece on its own: one that fails (a name taken, say) never keeps the others away.
747    // The tools first, so Claude can always run Standby when asked in words (a phone included).
748    for (const [name, description, props] of [
749      ['listen', 'Start listening to the meeting on this Mac. Its transcript then comes with the user\u2019s prompts (or as it goes, if they ask for it live)',
750        { pass: { type: 'string', enum: ['ask', 'live'], description: 'live only if the user asks for the transcript as it goes' } }],
751      ['stop', 'Stop listening to the meeting; Standby keeps its summary'],
752      ['call', 'Start a voice call with Standby, which hands real work to this session'],
753      ['end_call', 'End the voice call'],
754      ['meeting', 'Read the transcript of the meeting Standby recorded for this session (the latest, live or ended): use it whenever the user asks about the meeting and the transcript isn\u2019t already here'],
755      ['settings', 'Standby\u2019s settings: with no setting, lists each with its value and choices; with a setting and a value, changes it (only when the user asks)',
756        { setting: { type: 'string', description: 'the setting, as the list names it (transcript, capture, mic, voice, ...)' },
757          value: { type: 'string', description: 'one of its choices' } }],
758    ]) {
759      try { await $.tool.register({ name, description, inputSchema: { type: 'object', properties: props || {} } }) }
760      catch (err) { $.ui.log('the ' + name + ' tool isn\u2019t available: ' + err.message) }
761    }
762    try { await $.command.register({ name: 'standby', description: 'Standby: Claude listens to your meeting with you', argumentHint: '[listen|stop|call|end|name|window|settings|set|off|help]', immediate: true }) }
763    catch (err) { $.ui.log('/standby isn\u2019t available (' + err.message + '); the band and asking Claude still work') }
764    // The band shows from the start: loading the plugin is asking for Standby
765    S.unsaved = !!(await $.env.get('CLAUDE_CODE_CHILD_SESSION'))
766    void turnOn($, true)
767    $.clock.every(2000, async () => { if (S.on) await refresh($) })
768    $.clock.every(1000, () => { if (S.on && moving()) $.ui.invalidate('ui.render') })
769    return started
770  })
771
772  on('command.run', { command: 'standby' }, async ($, e) => {
773    const arg = String(e.args || '').trim()
774    if (arg === 'off') { S.on = false; $.ui.invalidate('ui.render'); return { text: 'Standby is off for this session.' } }
775    if (arg === 'listen' || arg === 'listen ask' || arg === 'listen live') return { text: await startListening($, arg.split(' ')[1]) }
776    if (arg === 'stop') return { text: await stopListening($) }
777    if (arg === 'window') return { text: await openWindow($) }
778    if (arg === 'settings') return { text: await settings($) }
779    if (arg === 'set' || arg.startsWith('set ')) {
780      const got = setArgs(arg.slice(3))
781      return { text: got ? await settings($, got.name, got.value) : 'Which one, to what? For example /standby set transcript live; /standby settings lists them.' }
782    }
783    if (arg === 'name' || arg.startsWith('name ')) return { text: await nameSpeaker($, arg.slice(4).trim()) }
784    if (arg === 'call') return { text: await startCall($) }
785    if (arg === 'end') return { text: await endCall($) }
786    if (arg === 'pause' || arg === 'resume') {
787      const live = S.st && S.st.live
788      if (!live || live.kind !== 'meeting') return { text: 'Nothing is recording.' }
789      if ((arg === 'pause') === !!live.paused) return { text: live.paused ? 'Already paused.' : 'Already listening.' }
790      return { text: await pause($) }
791    }
792    if (arg) {                                   // help, or anything it doesn't know: the same list
793      if (!S.st) await refresh($)
794      return { text: helpText(S.st && S.st.can) }
795    }
796    const said = await turnOn($)
797    if (!S.toasted && S.st && S.st.app) { S.toasted = true; $.ui.toast('Standby is on for this session') }
798    return { text: said }
799  })
800
801  on('tool.call', { tool: /^mcp__standby__/ }, async ($, e) => {
802    const act = e.tool.replace('mcp__standby__', '')
803    S.holding++
804    try {
805    if (ASK_FIRST[act]) {
806      const asked = await askToStart($, act)
807      if (asked.result) return { result: asked.result }
808      if (asked.accepted) {                     // they said yes: it starts on the band; Claude is told at once
809        if (act === 'call') void startCall($)
810        else void startListening($, ['ask', 'live'].includes(e.pass) ? e.pass : undefined)
811        return { result: act === 'call' ? 'Accepted: the call is starting' : 'Accepted: listening is starting' }
812      }
813    }
814    const how = ['ask', 'live'].includes(e.pass) ? e.pass : undefined     // the tool's arguments ride on the event
815    const answer = act === 'listen' ? await startListening($, how) : act === 'stop' ? await stopListening($)
816      : act === 'call' ? await startCall($) : act === 'end_call' ? await endCall($)
817      : act === 'meeting' ? await meetingText($) : act === 'settings' ? await settings($, e.setting, e.value)
818      : 'No such Standby action'
819    return { result: answer }
820    } finally { S.holding-- }
821  })
822
823  // Turns: whether the session is busy, and a call's progress back to the voice
824  on('turn.start', async ($, e, next) => { S.turn = e.turnId; return next(e) })
825  on('turn.complete', async ($, e, next) => {
826    S.turn = null
827    const due = S.later.splice(0)
828    for (const t of due) $.prompt.submit(t.asUser ? { text: t.text, asUser: true } : { text: t.text })
829    if (S.call && !e.agentId) {
830      const reason = e.isAborted ? 'aborted' : (e.reason || 'answer')
831      await app($, '/api/lite/progress', { session: S.call.session, turnId: e.turnId, kind: 'done', text: e.answer || '', reason })
832    }
833    return next(e)
834  })
835
836  // In a call: what the session is doing, so the voice can say it when asked ("it's running the tests")
837  on('tool.call', async ($, e, next) => {
838    if (S.call && !e.agentId) {
839      const now = await $.clock.now()
840      if (now - S.saidTool > 2000) {
841        S.saidTool = now
842        void app($, '/api/lite/progress', { session: S.call.session, turnId: S.turn || '', kind: 'tool', text: toolWords(e) })
843      }
844    }
845    return next(e)
846  })
847
848  // What the person types during a meeting carries the lines Claude hasn't had yet: on ask, all of
849  // the transcript the first time, then what's new; set to live, the lines since the last batch
850  on('prompt.submit', async ($, e, next) => {
851    // A letter on the terminal band, typed alone, then Enter: its action runs and nothing goes to Claude. Any
852    // other prompt (that letter with more text, a letter the band doesn't show now) is a prompt as ever
853    const typed = String(e.text || '').trim().toLowerCase()
854    const mine = e.origin && e.origin.kind === 'plugin' && e.origin.name === 'standby'      // a hand-off of the voice's
855    const hit = S.on && !e.command && !mine && /^[a-z]$/.test(typed) ? S.keysNow.find(k => k[0] === typed) : null
856    if (hit && (await $.session.surfaces()).includes('terminal')) {
857      S.keyUsed = true
858      const r = await press($, hit[1])
859      return { drop: r === 'closed' ? 'Done.' : r === 'asked' ? S.confirm.question : r }
860    }
861    if (summaryShown() && !choosing()) { S.card.seen = true; $.ui.invalidate('ui.render') }
862    const live = S.st && S.st.live
863    // On a call, what the person types or pastes here (never the voice's own hand-offs, a band key, a
864    // command, or another session's message) reaches the call: the voice hears of it, and says the answer
865    const own = !e.origin || e.origin.kind === 'composer' || e.origin.kind === 'bridge'
866    if (S.on && live && live.kind === 'call' && !e.command && !mine && own) {
867      const images = (e.attachments || []).filter(a => a && a.type === 'image').length
868      await app($, '/api/lite/progress', { session: live.session, kind: 'typed', text: String(e.text || ''), images })
869    }
870    if (!S.on || !live || live.kind !== 'meeting' || (live.pass !== 'ask' && !S.pending.length)) return next(e)
871    const extra = await batch($, live.session, true)
872    return extra ? next({ ...e, context: [...(e.context ?? []), extra] }) : next(e)
873  }).catch(async ($, e, next) => next(e))
874
875  // The band above the prompt, as Claude Code's own spinner row reads ("✻ Thinking… (12s · esc to
876  // interrupt)"). With nothing live and no summary waiting, there's no band at all. While something is live: one line that never wraps (the mark, what it
877  // is, its name cut short rather than wrapped, the minutes, Full window) and under it, dim, the
878  // latest of it, so you can see it's working: a meeting's last finished line, a call's last exchange
879  // (each changes only when a line or a turn is done). In VS Code and the desktop, quiet text links beside
880  // Full window: Pause (Resume while paused) and End, short (the user, 2026-10-21); the
881  // terminal keeps its letter keys; /standby and plain words do the same. After a meeting or a
882  // call, its summary, the one time the band takes more room: its points, until the next prompt or
883  // about 30 s (everything is kept in Standby; nothing to choose), then nothing.
884  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
885    if (e.surface !== 'terminal') return band($, e, next)
886    S.keysNow = []
887    const tree = await band($, e, next)
888    if (!S.keysNow.length || S.keyUsed) return tree
889    // the first time it offers keys: how they work, dim, under them (once per Claude Code session)
890    S.hintAt = S.hintAt || Date.now()
891    if (Date.now() - S.hintAt > HINT_MS) return tree
892    const { Box, Text } = $.ui.resolve(e)
893    return Box({ flexDirection: 'column', children: [tree, Box({ flexDirection: 'row', children: [Text({ dimColor: true, children: '  type a letter and press Enter' })] })] })
894  })
895
896
897  // In the chat: a meeting note reads as one dim line, a call's hand-off says it came from the call
898  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
899    const text = String((e.props && e.props.text) || '')
900    const line = noteLine(text)
901    if (line) { const { Text } = $.ui.resolve(e); return Text({ dimColor: true, children: line }) }
902    if (S.fromCall.has(text.trim())) {
903      const { Box, Text } = $.ui.resolve(e)
904      return Box({ flexDirection: 'column', children: [Text({ dimColor: true, children: 'from your call' }), await next(e)] })
905    }
906    return next(e)
907  })
908
909}
910
hooks/words.js 143 lines
1// What Standby says inside Claude Code: pure functions, no mods API, so they test on their own.
2
3// "12:04" from seconds
4export function clock(s) {
5  s = Math.max(0, Math.floor(s || 0))
6  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60
7  return (h ? h + ':' + String(m).padStart(2, '0') : String(m)) + ':' + String(r).padStart(2, '0')
8}
9
10// "14:02" from an epoch in seconds, local time
11export function hhmm(t) {
12  const d = new Date((t || 0) * 1000)
13  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
14}
15
16// One transcript line as it reads in the pane and in a batch: "Nadia: it ships Friday"
17export function said(line, names) {
18  const who = (names && names[line.speaker]) || line.name || line.speaker || 'Someone'
19  return who + ': ' + String(line.text || '').trim()
20}
21
22// A batch of meeting lines, labelled so it reads on its own (Claude, and Remote Control on a phone)
23export function batchText(lines, names, title) {
24  if (!lines.length) return ''
25  const span = hhmm(lines[0].t) + (lines.length > 1 ? '-' + hhmm(lines[lines.length - 1].t) : '')
26  return '[Standby · ' + (title || 'meeting') + ' · ' + span + ']\n' + lines.map(l => said(l, names)).join('\n')
27}
28
29// A Standby note as the chat shows it: "Standby · meeting lines 10:00 to 10:02", "Standby · meeting
30// started 10:00", or null for any other message
31export function noteLine(text) {
32  const m = /^\[Standby · ([^\]·]+?) · (\d\d:\d\d)(?:-(\d\d:\d\d))?\]/.exec(String(text || ''))
33  if (!m) return null
34  if (/ started$/.test(m[1])) return 'Standby · ' + m[1] + ' ' + m[2]
35  if (/ summary$/.test(m[1])) return 'Standby · ' + m[1] + ' ' + m[2] + (m[3] ? ' to ' + m[3] : '')
36  return 'Standby · ' + m[1] + ' lines ' + m[2] + (m[3] ? ' to ' + m[3] : '')
37}
38
39// On ask, the one note when a meeting starts: Claude knows there's a meeting, and how to read it
40export function startNote(title, now) {
41  return '[Standby · meeting started · ' + hhmm(now || Date.now() / 1000) + ']\n' +
42    'Standby is recording a meeting' + (title ? ' (' + title + ')' : '') + ' on the user\u2019s Mac. Its transcript comes with ' +
43    'the user\u2019s prompts while it runs; afterwards, the standby meeting tool reads it. Nothing to answer now.'
44}
45
46// Split a stream's text into whole JSON lines, keeping a partial tail for the next chunk
47export function jsonLines(buffer, chunk) {
48  const text = buffer + chunk, parts = text.split('\n'), rest = parts.pop()
49  const out = []
50  for (const p of parts) {
51    const s = p.trim()
52    if (!s) continue
53    try { out.push(JSON.parse(s)) } catch { /* not ours: skip */ }
54  }
55  return { events: out, rest }
56}
57
58// What a tool call is doing, in a few words the voice can say
59export function toolWords(e) {
60  const t = e.tool || ''
61  if (t === 'Bash') return 'running ' + String(e.description || e.command || 'a command').slice(0, 80)
62  if (t === 'Edit' || t === 'Write' || t === 'MultiEdit') return 'editing ' + String(e.file_path || 'a file').split('/').pop()
63  if (t === 'Read') return 'reading ' + String(e.file_path || 'a file').split('/').pop()
64  if (t === 'Grep' || t === 'Glob') return 'searching the code'
65  if (t === 'WebSearch' || t === 'WebFetch') return 'looking it up online'
66  if (t === 'Agent' || t === 'Task') return 'handing part of it to a helper'
67  return 'using ' + t.replace(/^mcp__[^_]+__/, '')
68}
69
70// Speech to text ends a line at every pause, so one sentence can come as several ("It didn't
71// actually open" / "uh, in a" / ...). A line continues the one before when the same person goes on
72// within a moment and the one before hasn't ended its sentence. Shown joined; the transcript keeps them apart.
73export const JOIN_GAP = 2.5          // s between one line's end and the next one's start
74export function continues(prev, line) {
75  if (!prev || prev.speaker !== line.speaker) return false
76  if (/[.?!\u2026]["\u201d')\]]?\s*$/.test(String(prev.text || ''))) return false
77  const end = prev.end != null ? prev.end : prev.t
78  return line.t != null && end != null && line.t - end <= JOIN_GAP && String(prev.text || '').length < 400
79}
80export function joined(lines) {
81  const out = []
82  for (const l of lines) {
83    const last = out[out.length - 1]
84    if (last && continues(last, l)) out[out.length - 1] = Object.assign({}, last, { text: last.text.trim() + ' ' + String(l.text || '').trim(), end: l.end != null ? l.end : l.t })
85    else out.push(Object.assign({}, l))
86  }
87  return out
88}
89
90// The band's time: whole minutes, so it changes once a minute ("4m", or "4 min" where there's room)
91export function minutes(s, long) {
92  const m = Math.floor(Math.max(0, Number(s) || 0) / 60)
93  return long ? (m < 1 ? 'under a minute' : m + ' min') : (m < 1 ? '<1m' : m + 'm')
94}
95
96// "/standby name Speaker 1 Nadia": the longest speaker label the words start with, then the name
97export function nameArgs(args, labels) {
98  const text = String(args || '').trim(), low = text.toLowerCase()
99  const hit = labels.filter(l => low.startsWith(String(l.label).toLowerCase() + ' ') || low.startsWith(String(l.speaker).toLowerCase() + ' '))
100    .map(l => ({ l, n: low.startsWith(String(l.label).toLowerCase() + ' ') ? String(l.label).length : String(l.speaker).length }))
101    .sort((a, b) => b.n - a.n)[0]
102  if (!hit) return null
103  const name = text.slice(hit.n).trim()
104  return name ? { speaker: hit.l.speaker, label: hit.l.label, name } : null
105}
106
107// /standby help: each subcommand in one plain line, and the plain-words way
108export function helpText(can) {
109  const rows = [
110    ['/standby', 'turn Standby on for this session'],
111    ['/standby listen', 'listen to the meeting; Claude gets the transcript with your prompts'],
112    ['/standby listen live', 'listen, and pass the transcript to Claude as it goes'],
113    ['/standby pause', 'pause listening (/standby resume to go on)'],
114    ['/standby stop', 'stop listening; the summary shows here and stays in Standby'],
115    ['/standby name Speaker 1 Nadia', 'name a speaker; Standby will know their voice'],
116  ]
117  if (can && can.window) rows.push(['/standby window', 'open Standby on this session'])
118  rows.push(['/standby call', 'talk with Standby\u2019s voice (/standby end to hang up)'])
119  rows.push(['/standby settings', 'Standby\u2019s settings, their values and choices'],
120    ['/standby set transcript live', 'change one (any setting, any of its choices)'])
121  rows.push(['/standby off', 'turn Standby off for this session'], ['/standby help', 'this list'])
122  const width = Math.max(...rows.map(r => r[0].length))
123  // a first line of its own: Claude Code puts the plugin's name in front of it
124  return 'what it does here:\n' + rows.map(r => r[0].padEnd(width + 2) + r[1]).join('\n') +
125    '\n\nOr just ask Claude in your own words: \u201cstart listening\u201d, \u201cstop listening\u201d, \u201cwhat did they decide?\u201d.'
126}
127
128// /standby settings: one line each, "transcript   ask (ask, live)   how a meeting's transcript ...";
129// a long list of choices (the voices, the mics) shows its first few
130export function settingsText(list) {
131  if (!list || !list.length) return 'Standby didn\u2019t list its settings.'
132  const shown = c => c.length > 6 ? c.slice(0, 6).join(', ') + ', and ' + (c.length - 6) + ' more' : c.join(', ')
133  const width = Math.max(...list.map(s => s.name.length))
134  return 'Standby\u2019s settings:\n' + list.map(s => s.name.padEnd(width + 2) + s.value + '  (' + shown(s.choices || []) + ')  ' + s.about).join('\n') +
135    '\n\nChange one with /standby set <name> <value>, or ask Claude.'
136}
137
138// "/standby set mic AirPods Pro": the name, then the rest as the value
139export function setArgs(args) {
140  const m = /^(\S+)\s+(.+)$/.exec(String(args || '').trim())
141  return m ? { name: m[1], value: m[2].trim() } : null
142}
143