SLOPSHOPPER

Better Call GPT

Voice-call this Claude Code session: Call, Steer and Hang up above the prompt. macOS.

newbandcommandtoastpromptprocess
★ 8v0.2.1MITupdated 2026-10-06insta-fusion/bettercallgpt/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bettercallgpt
› 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 › /call-icons ⎿ bettercallgpt: icons: keys (choose keys, nerd, emoji or none) ⟨Claude Code's own drawing⟩ ◌ HANGING UP saying goodbye… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ◌ HANGING UP saying goodbye…
README

Better Call GPT

Put this Claude Code session on a full-duplex voice call. You talk while it codes, interrupt it at any time, and hear results read back. A spoken "yes" never approves anything: permission prompts stay on your keyboard. macOS only.

Commands

  • /bettercallgpt:on starts a voice call bound to this session. Claude Code asks you to approve the start.
  • /bettercallgpt:status shows whether the call is live.
  • /bettercallgpt:off ends the call.

On Claude Code 2.1.287+ (CLI and the desktop Code tab) a row above the prompt does the same with one press: Call, Steer (send what you just said now; Claude's running turn makes way; unsent words exist only on the GPT-Live voice, on Voice Live Steer brings a waiting message forward) and Hang up (/clear and closing the session end the call too). /call, /steer and /hangup are the same actions; /call-icons picks the symbols.

Get started

  1. Install uv (the voice process runs through it).
  2. Copy .env.example to ~/.config/bettercallgpt/.env and paste your voice key into that file, not into the chat. Keys come from Azure (Voice Live or GPT-Live) or OpenAI (Realtime).
  3. Press Call above the prompt, or type /bettercallgpt:on. A rising tone means you are live.

What it runs, sends and fetches

Everything below is the whole list. The plugin is one hooks module (the Mod, hooks/register.tsx) and three commands; the voice itself is a separate program, bettercallgpt, from this repository.

The Mod runs one program

  • Which: bettercallgpt, the voice process. Nothing else.
  • How, exactly: when you press Call, the Mod starts it as a child of your Claude Code process with one of these two commands, no shell and no inline script:
  • an installed command, if one is found as a file at ~/.local/bin/bettercallgpt, /opt/homebrew/bin/bettercallgpt or /usr/local/bin/bettercallgpt (checked as files, in that order; the Mod runs it once with the single fixed argument --help to see that it knows the console, and passes it over otherwise): bettercallgpt --session <session id> --nonce <nonce> --mod start
  • otherwise the pinned release through uv: uvx --from git+https://github.com/insta-fusion/bettercallgpt@v0.2.1 bettercallgpt --session <session id> --nonce <nonce> --mod start
  • <session id> is this Claude Code session's id; <nonce> is a fresh single-use token the Mod makes for that start. No other value is read into the command.
  • Hang up and Steer run the same program with the fixed arguments --session <session id> stop and --session <session id> steer.
  • Your press is the consent: no model turn runs and no permission prompt is shown. The voice process refuses this kind of start unless Claude Code itself spawned it.

What leaves your machine, and where

  • The Mod itself sends nothing. It makes no network request and opens no socket.
  • The voice process sends your microphone audio and the text of the call to the one realtime voice service you configured, with your own key: Azure Voice Live (*.cognitiveservices.azure.com, the default), the Azure GPT-Live API (*.openai.azure.com), or the OpenAI Realtime API (api.openai.com). When Claude makes a permission request during a call, a one-line summary of it (the tool's name and the Bash command, file path or MCP tool name) is spoken, so it goes to that service too. Nothing goes anywhere else, and nothing is sent when no call is running.
  • Fetches: the tagged release from GitHub, on first use, through uv.

What the Mod reads

  • The environment variables HOME, XDG_STATE_HOME and VOICE_LISTEN_STATE_DIR, to find the state directory: ~/.local/state/bettercallgpt unless one of the last two is set.
  • status.json in <state directory>/<session id>/, every 2 seconds (twice a second during a call). During a call that file holds the last 60 characters you said that are not sent yet (credentials masked), which the call row shows.
  • This session's id, to name that directory. The Mod reads no transcript and no other file.
  • Its own symbol choice (/call-icons), from the plugin's store.

The voice process reads your key from ~/.config/bettercallgpt/.env (you fill it yourself; setup never asks for it), and this session's own transcript and terminal pane, to relay results and to notice permission prompts.

The permission hook: what it decides and when

  • When: on every permission request Claude Code makes (classic.PermissionRequest), only while a call is live in this session. With no call it does nothing.
  • What it decides: nothing. It returns the request unchanged (next(e)), so the outcome is whatever your permission settings and your own hooks decide. It never allows, denies or rewords a request, and never changes what a dialog shows. Approvals stay on your keyboard; a spoken "yes" never approves anything.
  • What it does instead: writes the one file below, so the voice can say what Claude is asking for. That can include a request another hook or Claude Code then decides without asking you; sandbox network prompts are not covered.

The one file the Mod writes

  • Path: <state directory>/<session id>/permission.json, that is ~/.local/state/bettercallgpt/<session id>/permission.json by default.
  • Content: the time, the tool's name, a one-line summary and the call's instance id.
  • Who reads it: the voice process of this call, to speak it once. No other tool runs or obeys it.
  • The voice process writes its own files in that directory (a conversation ledger and status files); it is per-user and only you can open it (0700). They stay on your machine.

Off switch

"disableAllHooks": true in your settings turns the Mod off (with all your hooks), or disable the plugin in /plugin. The three commands keep working without it.

Where it works

Claude Code on macOS: the CLI in any terminal, and the Code tab of the Claude desktop app. It needs a Mac microphone, so it does nothing in Claude chat, Cowork, or Claude Code on the web.

Why the key stays in a file

The voice service key lives in ~/.config/bettercallgpt/.env rather than in plugin settings so that it never enters a prompt, a command line or the model's context; only the local voice process reads it.

"GPT" names the voice model the call runs on. This project is not affiliated with OpenAI, Microsoft or Anthropic.

Full guide, security notes and source: https://github.com/insta-fusion/bettercallgpt

Source 2 files
hooks/register.tsx 584 lines
1// Better Call GPT's hooks module: the call console.
2//
3// - ui.render AbovePrompt: one band. No call: [ Call ]. On a call: what the voice heard and has
4//   not handed over, how many spoken messages wait in Claude's queue, and [ Steer ] [ Hang up ].
5// - Call: starts the voice process as this session's child ($.process.spawn), on the operator's
6//   own press. No model turn runs and no permission prompt is involved: the press is the
7//   consent to open the microphone and the paid voice connection.
8// - Hang up: the voice process's own `stop` (goodbye, falling tone, device released).
9// - Steer: "take what I said now". The voice process is asked to hand over what it heard; and
10//   when a spoken message waits behind Claude's running turn, that turn is ended so the message
11//   is read next. Nothing is ever sent twice.
12// - /call, /hangup, /steer do the same as the buttons.
13// - classic.PermissionRequest: while on a call, write permission.json so the voice can say
14//   Claude is asking for a permission. Observed only: the hook returns what next(e) returns.
15//   Approvals stay on the keyboard.
16//
17// It reads status.json (written by the voice process) and writes only permission.json, both in
18// <state root>/<session id>/, the directory the voice process created (0700).
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register } from 'claude-code'
21
22import type { CallView } from '../types'
23
24const APP = 'bettercallgpt'
25// The release this plugin starts when no `bettercallgpt` is installed on PATH.
26const RELEASE = 'git+https://github.com/insta-fusion/bettercallgpt@v0.2.1'
27const TICK_MS = 500
28// With no call, status.json is read every IDLE_TICKS ticks (a call started with
29// /bettercallgpt:on shows up within two seconds).
30const IDLE_TICKS = 4
31// A Hang up the voice process has not answered after this long ends the child directly.
32const HANGUP_BOUND_MS = 8000
33// An installed command that does not answer `--help` in this long is passed over.
34const PROBE_BOUND_MS = 5000
35// Longer than this after flattening, no summary is written at all (the tool's name still is):
36// a cut command could split a credential the voice process would then fail to mask.
37const SUMMARY_MAX = 2000
38// The voice process refreshes `at` in status.json every 10 s while the call runs; older than
39// this, the status is one a killed process left behind. A little ahead is clock skew.
40const STALE_AFTER_S = 30
41const AHEAD_S = 5
42// The session ids the launcher accepts (bettercallgpt/cli.py SESSION_ID): a name, never a path.
43const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
44const FILE_TOOLS = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
45// A spoken message as it arrives in the session: its text ends with the voice tag.
46const VOICE_TAG = /⟨v#[^⟩]*⟩\s*$/
47
48// The band's colors are the terminal's own named colors, so they follow the person's theme:
49// green is live and "call", red is "hang up" and failure, yellow is waiting, cyan is the queue.
50const LIVE = 'green'
51const WARN = 'yellow'
52const QUEUE = 'cyan'
53const END = 'red'
54const INK = 'black'
55// Below this many columns the words heard and not sent get a row of their own.
56const NARROW = 80
57
58// The symbols on the controls, chosen with /call-icons and kept in this plugin's store.
59// `keys` (the default) and `nerd` draw each symbol as a small colored key before its button:
60// green to call, red to hang up, as on a phone. `keys` uses characters every font has; `nerd`
61// needs a Nerd Font and has a real hung-up handset and a steering wheel. `emoji` puts emoji in
62// the labels; `none` is words only.
63export const ICON_SETS = {
64  // Steer keeps the wheel emoji in its label: no plain character reads as a steering wheel.
65  keys: { call: '☎', steer: '🛞 ', hangup: '☎', live: '● ' },
66  nerd: { call: '\uf095', steer: '\u{f04d4}', hangup: '\u{f03fa}', live: '\u{f036c} ' },
67  emoji: { call: '📞 ', steer: '🛞 ', hangup: '📴 ', live: '🎙 ' },
68  none: { call: '', steer: '', hangup: '', live: '● ' },
69} as const
70export type IconSet = keyof typeof ICON_SETS
71const ICONS_KEY = 'icons'
72
73export const IDLE: CallView = { phase: 'idle', unsent: '', queued: 0, working: false, note: '' }
74
75const call = atom({ plugin: 'bettercallgpt', key: 'call' } as const, IDLE)
76
77type Child = AsyncGenerator<unknown, unknown, unknown>
78type Status = Record<string, unknown>
79type LiveCall = { dir: string; instance: string; status: Status }
80
81// This module's own memory. A reload starts it over, and a reload also ends the child.
82let child: Child | undefined
83let command: string | undefined // an installed bettercallgpt that fits, once probed
84let probed = false
85let mainTurn = ''
86let steerWaits = false
87let isStarting = false
88let steerShown = ''
89let icons: IconSet = 'keys'
90let ticks = 0
91let isPolling = false
92
93function set($: EngineInterface, patch: Partial<CallView>) {
94  return update($, call, view => ({ ...view, ...patch }))
95}
96
97/** Where the voice process keeps its state. The order mirrors bettercallgpt/cli.py
98 * user_state_dir + apply_defaults, which main() applies before daemon.main in the same process. */
99async function stateRoot($: EngineInterface): Promise<string | undefined> {
100  const absolute = (path: string | undefined) =>
101    path?.startsWith('/') ? path.replace(/\/+$/, '') : undefined
102  const explicit = absolute(await $.env.get('VOICE_LISTEN_STATE_DIR'))
103  if (explicit !== undefined) return explicit
104  const xdg = absolute(await $.env.get('XDG_STATE_HOME'))
105  if (xdg !== undefined) return `${xdg}/${APP}`
106  const home = absolute(await $.env.get('HOME'))
107  return home === undefined ? undefined : `${home}/.local/state/${APP}`
108}
109
110/** The rule of `bettercallgpt statusline` with a fresh heartbeat in place of its pid probe:
111 * running, relay qualified, not ended (an empty `ended` object is "not ended", as in Python),
112 * and a finite `at` (epoch seconds) between AHEAD_S ahead and STALE_AFTER_S behind now. */
113export function isLive(status: unknown, nowSeconds: number): boolean {
114  if (typeof status !== 'object' || status === null) return false
115  const { phase, relay, ended, at } = status as Status
116  const hasEnded =
117    typeof ended === 'object' && ended !== null ? Object.keys(ended).length > 0 : Boolean(ended)
118  const age = typeof at === 'number' && Number.isFinite(at) ? nowSeconds - at : NaN
119  const isFresh = age >= -AHEAD_S && age <= STALE_AFTER_S
120  return phase === 'running' && relay === 'qualified' && !hasEnded && isFresh
121}
122
123/** This session's live call (its state directory, the voice process's instance id and its
124 * status), or undefined. Rejects when status.json cannot be read or parsed. */
125async function liveCall($: EngineInterface, sessionId: string): Promise<LiveCall | undefined> {
126  if (!SESSION_ID.test(sessionId)) return undefined
127  const root = await stateRoot($)
128  if (root === undefined) return undefined
129  const dir = `${root}/${sessionId}`
130  const statusPath = `${dir}/status.json`
131  if (!(await $.fs.exists(statusPath))) return undefined
132  const status: unknown = JSON.parse(await $.fs.read(statusPath))
133  if (!isLive(status, (await $.clock.now()) / 1000)) return undefined
134  const { instance } = status as Status
135  return { dir, instance: typeof instance === 'string' ? instance : '', status: status as Status }
136}
137
138/** One line naming what the request is about: the Bash command, the file, or the MCP tool;
139 * empty when that line is longer than SUMMARY_MAX (never a partial command). */
140export function summarize(tool: string, input: unknown): string {
141  const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
142  const text = (value: unknown) => (typeof value === 'string' ? value : '')
143  let summary = ''
144  if (tool === 'Bash') summary = text(fields.command)
145  else if (FILE_TOOLS.has(tool)) summary = text(fields.file_path) || text(fields.notebook_path)
146  else if (tool.startsWith('mcp__')) summary = tool
147  const line = summary.replace(/\s+/g, ' ').trim()
148  return line.length <= SUMMARY_MAX ? line : ''
149}
150
151/** What status.json says the band should show. */
152export function liveFields(status: Status): Pick<CallView, 'unsent' | 'queued'> {
153  const unsent = status.unsent
154  const preview =
155    typeof unsent === 'object' && unsent !== null ? (unsent as Status).preview : undefined
156  const queued = Array.isArray(status.queued) ? status.queued.length : 0
157  return { unsent: typeof preview === 'string' ? preview : '', queued }
158}
159
160/** The last line of what a failed start wrote, as one short note. */
161export function failure(stderr: string, code: unknown): string {
162  const lines = stderr.split('\n').map(line => line.trim()).filter(line => line !== '')
163  const last = (lines.pop() ?? '').replace(/^voice:\s*/, '')
164  if (last.includes('bind_no_transcript')) return 'send Claude one message first, then call again'
165  if (last.includes('missing ')) return `${last.split(' — ')[0]} — add your key, see the README`
166  if (last === '') return `the voice process ended (exit ${String(code)})`
167  return last.length <= 160 ? last : `${last.slice(0, 159)}…`
168}
169
170/** Where `uv tool install` and Homebrew put an installed `bettercallgpt`. Checked as files, in
171 * this order; no shell and no PATH search is run to find the command. */
172const INSTALLED_AT = ['.local/bin/bettercallgpt', '/opt/homebrew/bin/bettercallgpt', '/usr/local/bin/bettercallgpt']
173
174/** An installed `bettercallgpt` that knows the console's commands (a checkout installed for
175 * development), or undefined: then the pinned release runs through `uvx`. */
176async function installed($: EngineInterface): Promise<string | undefined> {
177  if (probed) return command
178  probed = true
179  const home = await $.env.get('HOME')
180  for (const at of INSTALLED_AT) {
181    const path = at.startsWith('/') ? at : `${home ?? ''}/${at}`
182    if (!path.startsWith('/') || !(await $.fs.exists(path).catch(() => false))) continue
183    // One program, by name, with one fixed argument: an installed command from before the call
184    // console knows neither `--mod` nor `steer`, and is passed over for the release.
185    const fits = await $.process
186      .run([path, '--help'], { timeoutMs: PROBE_BOUND_MS })
187      .then(result => result.exitCode === 0 && result.stdout.includes('--mod'), () => false)
188    if (fits) command = path
189    break
190  }
191  return command
192}
193
194/** The command for `stop` and `steer`: the installed command or the release through uvx. */
195async function voiceCommand($: EngineInterface): Promise<string[]> {
196  const bin = await installed($)
197  return bin !== undefined ? [bin] : ['uvx', '--from', RELEASE, 'bettercallgpt']
198}
199
200/** Read the child's output until it ends; then the call is over, whatever ended it. */
201async function follow($: EngineInterface, stream: Child) {
202  let stderr = ''
203  let code: unknown
204  try {
205    for (;;) {
206      const piece = await stream.next()
207      if (piece.done) {
208        code = (piece.value as { code?: unknown } | undefined)?.code
209        break
210      }
211      const { stream: pipe, text } = piece.value as { stream: string; text: string }
212      if (pipe === 'stderr') stderr = (stderr + text).slice(-4000)
213    }
214  } catch (error) {
215    stderr = String(error)
216  }
217  if (child !== stream) return // a newer call owns the band
218  child = undefined
219  steerWaits = false
220  try {
221    const { phase } = await read($, call)
222    const note = phase === 'starting' ? failure(stderr, code) : ''
223    await set($, { ...IDLE, working: mainTurn !== '', note })
224  } catch {
225    // The module was unloaded while the child was ending: there is no band left to update.
226  }
227}
228
229async function startCall($: EngineInterface): Promise<string> {
230  // Two presses in a row: the flag is taken before anything is awaited, so one call starts.
231  if (isStarting || child !== undefined) return 'a call is already on'
232  isStarting = true
233  try {
234    const view = await read($, call)
235    if (view.phase !== 'idle') return 'a call is already on'
236    const sessionId = await $.session.id()
237    if (!SESSION_ID.test(sessionId)) return 'this session has no usable id'
238    const nonce = `mod-${(await $.clock.now()).toString(36)}-${Math.random().toString(36).slice(2, 10)}`
239    // The whole command, as text: the installed command, or the pinned release through uvx.
240    // `sessionId` and `nonce` are the only values; nothing else is read from the environment.
241    const bin = await installed($)
242    const argv =
243      bin !== undefined
244        ? [bin, '--session', sessionId, '--nonce', nonce, '--mod', 'start']
245        : ['uvx', '--from', RELEASE, 'bettercallgpt', '--session', sessionId, '--nonce', nonce, '--mod', 'start']
246    const stream = $.process.spawn({ argv, env: { NONCE: nonce } }) as unknown as Child
247    child = stream
248    await set($, { ...IDLE, phase: 'starting', working: mainTurn !== '', note: '' })
249    void follow($, stream)
250    return 'calling'
251  } finally {
252    isStarting = false
253  }
254}
255
256async function hangUp($: EngineInterface): Promise<string> {
257  const view = await read($, call)
258  if (view.phase === 'idle') return 'no call to hang up'
259  const sessionId = await $.session.id()
260  await set($, { phase: 'ending', note: '' })
261  steerWaits = false
262  const ending = child
263  if (ending !== undefined) {
264    // The voice process says goodbye and exits by itself; one that does not is ended here.
265    // Armed before the stop is sent: a stop that hangs must not leave the call running.
266    $.clock.after(HANGUP_BOUND_MS, () => {
267      if (child === ending) void ending.return(undefined)
268    })
269  }
270  await $.process
271    .run([...(await voiceCommand($)), '--session', sessionId, 'stop'])
272    .catch(() => undefined)
273  return 'hanging up'
274}
275
276/** End Claude's running turn when a Steer is waiting and a spoken message sits behind it.
277 * The status is read again right here: a message Claude took in the meantime (it is no longer
278 * queued) must not cost the turn that is now working on it. */
279async function bringForward($: EngineInterface) {
280  if (!steerWaits) return
281  const turnId = mainTurn
282  if (turnId === '') {
283    steerWaits = false // idle: a message starts its own turn
284    return
285  }
286  const sessionId = await $.session.id()
287  const live = await liveCall($, sessionId).catch(() => undefined)
288  if (live === undefined || liveFields(live.status).queued === 0) return
289  if (!steerWaits || turnId !== mainTurn) return // answered while reading
290  steerWaits = false
291  try {
292    await $.turn.abort({ turnId })
293    $.ui.toast('Steer: Claude takes your message now')
294  } catch {
295    // That turn ended by itself in the meantime: the message is read next anyway.
296  }
297}
298
299async function chooseIcons($: EngineInterface, wanted: string): Promise<string> {
300  const name = wanted.trim()
301  if (!Object.hasOwn(ICON_SETS, name)) return `icons: ${icons} (choose keys, nerd, emoji or none)`
302  icons = name as IconSet
303  await $.store.set(ICONS_KEY, icons)
304  await update($, call, view => ({ ...view })) // draw the band again
305  return `icons: ${icons}`
306}
307
308/** What the voice process answered to the last Steer, as the band's note. */
309export function steerNote(result: unknown): string {
310  if (typeof result !== 'string' || result === '') return ''
311  if (result === 'sent') return ''
312  if (result === 'nothing_unsent') return ''
313  if (result === 'uncertain') return 'steer: not confirmed (it is not sent again)'
314  return `steer did not send (${result.slice(0, 60)})`
315}
316
317async function steer($: EngineInterface): Promise<string> {
318  const view = await read($, call)
319  if (view.phase !== 'live') return 'no call to steer'
320  const sessionId = await $.session.id()
321  // A Steer waits only for the turn it was pressed in (turn.complete clears it).
322  steerWaits = mainTurn !== ''
323  // The voice process sends what it heard and has not handed over, itself, as a message that
324  // ends Claude's running turn. Its answer comes back in status.json (`steer`).
325  await $.process
326    .run([...(await voiceCommand($)), '--session', sessionId, 'steer'])
327    .catch(() => undefined)
328  await bringForward($)
329  return 'steering'
330}
331
332async function poll($: EngineInterface, isFirst = false) {
333  if (isPolling) return // one read at a time: a slow one never lands after a newer one
334  isPolling = true
335  try {
336    const view = await read($, call)
337    ticks += 1
338    if (!isFirst && view.phase === 'idle' && ticks % IDLE_TICKS !== 0) return
339    const sessionId = await $.session.id()
340    // A status that cannot be read or parsed is no call.
341    const live = await liveCall($, sessionId).catch(() => undefined)
342    // A /clear while reading moved to another session: this answer is for the old one.
343    if (sessionId !== (await $.session.id())) return
344    if (live === undefined) {
345      // No child and no live status: the call (one started by /bettercallgpt:on) is over.
346      if (child === undefined && view.phase !== 'idle') await set($, { ...IDLE, working: view.working })
347      return
348    }
349    const fields = liveFields(live.status)
350    // The voice process's answer to a Steer, shown once per press.
351    const answer = (live.status.steer ?? {}) as Status
352    const answerId = typeof answer.id === 'string' ? answer.id : ''
353    let note: string | undefined
354    if (answerId !== '' && answerId !== steerShown) {
355      // What happened is said once, in passing; only a failure stays in the band.
356      note = steerNote(answer.result)
357      if (answer.result === 'sent') $.ui.toast('Steer: sent what you said')
358      if (answer.result === 'nothing_unsent' && fields.queued === 0) $.ui.toast('Nothing to steer')
359      steerShown = answerId
360    }
361    const isSame =
362      view.phase === 'live' && view.unsent === fields.unsent && view.queued === fields.queued
363    if (!isSame || note !== undefined) {
364      // The phase is decided on the view as it is when written: a Hang up pressed while this
365      // read was out stays "ending".
366      await update($, call, now => ({
367        ...now,
368        ...fields,
369        phase: now.phase === 'ending' ? 'ending' : 'live',
370        note: note ?? now.note,
371      }))
372    }
373    await bringForward($)
374  } catch {
375    // The session itself could not be asked: try again on the next tick.
376  } finally {
377    isPolling = false
378  }
379}
380
381export const register: Register = on => {
382  on('classic.PermissionRequest', async ($, e, next) => {
383    try {
384      const live = await liveCall($, e.session_id)
385      if (live !== undefined) {
386        const at = (await $.clock.now()) / 1000
387        const summary = summarize(e.tool_name, e.tool_input)
388        // `instance` ties the notice to this call: a later call in the same session ignores it.
389        const event = { at, tool: e.tool_name, summary, instance: live.instance }
390        // The one file this module writes: <state root>/<session id>/permission.json, read by the
391        // voice process of this call only. The state root is ~/.local/state/bettercallgpt unless
392        // VOICE_LISTEN_STATE_DIR or XDG_STATE_HOME is set (see stateRoot).
393        await $.fs.write(`${live.dir}/permission.json`, JSON.stringify(event))
394      }
395    } catch {
396      // Observing failed. The prompt is the same either way.
397    }
398    return next(e)
399  })
400
401  on('session.start', async ($, e, next) => {
402    const kept = await $.store.get(ICONS_KEY).catch(() => undefined)
403    if (typeof kept === 'string' && Object.hasOwn(ICON_SETS, kept)) icons = kept as IconSet
404    try {
405      await $.command.register({
406        name: 'call-icons',
407        description: 'Symbols on the call controls: keys, nerd (Nerd Font), emoji or none',
408        argumentHint: '[keys|nerd|emoji|none]',
409        immediate: true,
410      })
411      await $.command.register({
412        name: 'call',
413        description: 'Start a voice call in this session',
414        immediate: true,
415      })
416      await $.command.register({ name: 'hangup', description: 'End the voice call', immediate: true })
417      await $.command.register({
418        name: 'steer',
419        description: 'Have Claude take what you just said now',
420        immediate: true,
421      })
422    } catch {
423      // A host that takes no commands still gets the band and its buttons.
424    }
425    await poll($, true)
426    $.clock.every(TICK_MS, () => void poll($))
427    return next(e)
428  })
429
430  on('session.end', async ($, e, next) => {
431    // The call belongs to the session that started it: a /clear or an exit ends it.
432    await hangUp($) // also a call /bettercallgpt:on started; with no call it does nothing
433    return next(e)
434  })
435
436  on('command.run', { command: 'call' }, async $ => ({ text: await startCall($) }))
437  on('command.run', { command: 'call-icons' }, async ($, e) => ({ text: await chooseIcons($, e.args) }))
438  on('command.run', { command: 'hangup' }, async $ => ({ text: await hangUp($) }))
439  on('command.run', { command: 'steer' }, async $ => ({ text: await steer($) }))
440
441  on('turn.start', async ($, e, next) => {
442    mainTurn = e.turnId
443    await set($, { working: true })
444    return next(e)
445  })
446
447  on('turn.complete', async ($, e, next) => {
448    // A subagent's turn completes here too; only the main turn's own end clears it.
449    if (e.agentId === undefined && e.turnId === mainTurn) {
450      mainTurn = ''
451      steerWaits = false // a Steer is about the turn it was pressed in
452      await set($, { working: false })
453    }
454    return next(e)
455  })
456
457  on('prompt.submit', async ($, e, next) => {
458    // A spoken message was just read by Claude: a Steer that was waiting for that is done.
459    if (e.origin.kind === 'peer' && VOICE_TAG.test(e.text)) steerWaits = false
460    return next(e)
461  })
462
463  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
464    const isDrawn = e.surface === 'terminal' || e.surface === 'desktop'
465    if (!isDrawn || e.props.hasSurvey) return next(e)
466    const view = await read($, call)
467    // The band is shared: keep what the mods after this one draw (mods_interface.md, "Pick
468    // where to draw"), and add one line under it.
469    const below = await next(e)
470    const { Box, Button, Text } = $.ui.resolve(e)
471    const icon = ICON_SETS[icons]
472    // Emoji carry their own colors, inside the label. The other symbols are one-color
473    // characters: each is drawn before its button as a key in the control's color.
474    const isKeyed = icons === 'keys' || icons === 'nerd'
475    const inLabel = (symbol: string) => (isKeyed ? '' : symbol)
476    const tint = (symbol: string, color: string) =>
477      isKeyed ? (
478        <Box flexShrink={0}>
479          <Text backgroundColor={color} color={INK} bold>
480            {` ${symbol} `}
481          </Text>
482        </Box>
483      ) : null
484    // A state pill keeps its width: when the row is tight the words beside it are cut, never it.
485    const pill = (words: string, color: string) => (
486      <Box flexShrink={0}>
487        <Text backgroundColor={color} color={INK} bold>
488          {words}
489        </Text>
490      </Box>
491    )
492    const isNarrow = e.props.bodyColumns < NARROW
493    if (view.phase === 'idle') {
494      const hasFailed = view.note !== ''
495      return (
496        <Box flexDirection="column">
497          {below}
498          <Box gap={1}>
499            {hasFailed ? pill(' ✕ CALL FAILED ', END) : null}
500            {tint(icon.call, LIVE)}
501            <Button
502              key="call"
503              label={`${inLabel(icon.call)}${hasFailed ? 'Call again' : 'Call'}`}
504              hotkey="c"
505              variant="primary"
506              onPress={() => void startCall($)}
507            />
508            {hasFailed ? (
509              <Text wrap="truncate-end">{view.note}</Text>
510            ) : (
511              <Text wrap="truncate-end">
512                <Text bold>Better Call GPT</Text>
513                <Text dimColor>{isNarrow ? '' : ' · talk to this session by voice'}</Text>
514              </Text>
515            )}
516          </Box>
517        </Box>
518      )
519    }
520    if (view.phase === 'starting' || view.phase === 'ending') {
521      return (
522        <Box flexDirection="column">
523          {below}
524          <Box gap={1}>
525            {pill(view.phase === 'starting' ? ' ◌ CONNECTING ' : ' ◌ HANGING UP ', WARN)}
526            <Text dimColor wrap="truncate-end">
527              {view.phase === 'starting' ? 'opening the microphone and the voice…' : 'saying goodbye…'}
528            </Text>
529            {view.phase === 'starting' ? tint(icon.hangup, END) : null}
530            {view.phase === 'starting' ? (
531              <Button key="hangup" label={`${inLabel(icon.hangup)}Hang up`} hotkey="h" onPress={() => void hangUp($)} />
532            ) : null}
533          </Box>
534        </Box>
535      )
536    }
537    const canSteer = view.unsent !== '' || view.queued > 0
538    const quote =
539      view.unsent === '' ? null : (
540        <Text color={WARN} italic wrap="truncate-start">
541          «{view.unsent}» not sent
542        </Text>
543      )
544    return (
545      <Box flexDirection="column">
546        {below}
547        <Box gap={1}>
548          {pill(` ${icon.live}LIVE `, LIVE)}
549          <Box flexGrow={1} flexShrink={1} gap={1} overflow="hidden">
550            {canSteer ? null : <Text dimColor>{view.note === '' ? 'listening' : view.note}</Text>}
551            {isNarrow ? null : quote}
552            {view.queued === 0 ? null : (
553              <Text color={QUEUE} bold>
554                {view.queued} queued
555              </Text>
556            )}
557            {canSteer && view.note !== '' ? <Text dimColor>{view.note}</Text> : null}
558          </Box>
559          <Box flexShrink={0} gap={1}>
560            {icons === 'nerd' ? tint(icon.steer, QUEUE) : null}
561            <Button
562              key="steer"
563              label={`${icons === 'nerd' ? '' : icon.steer}Steer`}
564              hotkey="s"
565              variant={canSteer ? 'primary' : 'secondary'}
566              dimColor={!canSteer}
567              onPress={() => void steer($)}
568            />
569            {tint(icon.hangup, END)}
570            <Button
571              key="hangup"
572              label={`${inLabel(icon.hangup)}Hang up`}
573              hotkey="h"
574              variant="secondary"
575              onPress={() => void hangUp($)}
576            />
577          </Box>
578        </Box>
579        {isNarrow ? quote : null}
580      </Box>
581    )
582  })
583}
584
types/index.d.ts 24 lines
1// The values this plugin keeps in $.state for the session.
2// `call`: what the band above the prompt draws.
3//   phase   idle (no call), starting (the voice process is coming up), live, ending
4//   unsent  the tail of what the voice heard and has not handed to Claude yet ('' when none)
5//   queued  how many spoken messages wait in Claude's queue behind the running turn
6//   working whether Claude is in a turn right now
7//   note    one line about the last thing that happened (a failed start, a Steer's result)
8
9export type CallPhase = 'idle' | 'starting' | 'live' | 'ending'
10
11export type CallView = {
12  phase: CallPhase
13  unsent: string
14  queued: number
15  working: boolean
16  note: string
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    bettercallgpt: { call: CallView }
22  }
23}
24