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

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.
/standby in Claude Code (signed in, Standby running). Nothing shows above the prompt while nothing is live./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./standby listen live, or /standby set transcript live (Standby's Settings has it too)./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.
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):
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.
hooks/register.js 910 lines1// 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}
910hooks/words.js 143 lines1// 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