WolfBud: a voice coworker (ElevenLabs agent + 3D wolf) that watches the session and can send prompts to Claude

A voice coworker beside your Claude Code session. WolfBud watches the session (your prompts, Claude's tool calls, failures, final answers, permission prompts). You talk things through with it on a call, and when you agree on something it sends the prompt to Claude for you.
Tested on Claude Code 2.1.292 (macOS, Google Chrome).
Claude session A ── wolfbud mod ── subscribe, events, long-poll ──┐
Claude session B ── wolfbud mod ── subscribe, events, long-poll ──┤
▼
bridge/server.mjs
127.0.0.1:4747 (one hub)
│ SSE
▼
one Chrome app window
roster + 3D wolf + one call
The hub is not a child of any Claude session. The first /wolfbud that finds it down runs bridge/launch.mjs, which starts server.mjs detached, waits for it to answer, and exits. Later sessions only subscribe; a session that is already subscribed only raises the window. Claude cannot inject a prompt from outside a session, so each mod stays: it is the hands for that session only. The hub never calls Claude. It queues a command; the matching mod pulls it and runs deliver(). Each pull also carries the call's state, so a reloaded session sees the present rather than a replay.
On a Mac with WolfBud.app installed (built from macos/), that app is the face. Everywhere else the face is the Chrome window below. WOLFBUD_FACE=chrome forces Chrome, and WOLFBUD_FACE=native uses the app, falling back to Chrome when the bundle is missing.
~/.wolfbud/chrome, so the mic grant sticks and a call can start without a click. The hub opens it once and raises it after that. Orca and terminal-browser are not faces for the wolf./wolfbud adds a roster row and a short name (auth, auth-2). The voice tools take that name and default to the focused session. A second session that needs you is a badge, or a [session event] on the call already live. Not a second call.[claude activity] updates, [claude event] for that session, and [session event] when another subscribed Claude finishes or waits on permission.wolfbud_send_to_claude (optional session) turns what you decided into a prompt in that session:now: a note goes into the running turn.after_current: the prompt is queued.It can also stop that Claude with wolfbud_stop_claude (only when you ask) and look up details with wolfbud_claude_activity.
The repo is a marketplace (.claude-plugin/marketplace.json), so the mod installs from GitHub into every session, desktop app included. Nothing to build: the window ships built in bridge/window.
claude plugin marketplace add leopiney/wolfbud-claude-mod
claude plugin install wolfbud@elevenlabs-mods
printf '{"api_key":"%s"}' "$ELEVENLABS_API_KEY" | claude plugin configure wolfbud@elevenlabs-mods --values-stdin # kept in secure storage; or export ELEVENLABS_API_KEY
Restart Claude Code. On first use the bridge finds or creates the agent in the key's ElevenLabs account and syncs it to the definition (see Changing the agent).
To install a clone instead, run ELEVENLABS_API_KEY=… pnpm run install-plugin from its root (--dry-run lists the steps), or by hand: pnpm install && pnpm window:build, then the commands above with claude plugin marketplace add "$(pwd)" (an absolute path; "." fails). Both marketplaces are named elevenlabs-mods, and only one can be added at a time: claude plugin marketplace remove elevenlabs-mods (which uninstalls the mod) before you switch.
The install is a copy cached by version, so to ship a change:
version in .claude-plugin/plugin.json when the commit changes what ships (window/ or the mod, minus its tests and docs). It bumps the patch once per branch, counted from origin/main, so a branch's commits ship as one release. VERSION_BUMP=minor git commit … (or major) picks the level, and VERSION_BUMP=none skips it. It then rebuilds the window when window/, the dependencies or the version changed, and checks the agent definition when it changed.claude plugin update wolfbud@elevenlabs-mods (auto-update is off by default for this marketplace) and a restart. An install from a clone also needs claude plugin marketplace update elevenlabs-mods first.While developing, load the repo copy instead (it hot-reloads):
claude --plugin-dir ./mods/wolfbud
/wolfbud | subscribe this session, open the pane, and show the one window |
/wolfbud call | same, focus this session, and start the call if it is not already live |
/wolfbud end | hang up the one call. Other subscriptions stay |
/wolfbud window | raise the window and focus this session |
/wolfbud status | hub, window, call, and anything missing |
/wolfbud hide | close the pane and fold it into one status line. /wolfbud unfolds it |
/wolfbud stop | unsubscribe this session only. Another Claude's wolf stays up |
The pane's buttons (c call, e end, w window, h hide) do the same. In the fullscreen layout the pane docks beside the transcript.
A Claude session in a sandbox, a VM or a container can join the same call. The hub always runs on your own machine, because that's where Chrome, the mic and the ElevenLabs key are. The mod only makes outbound requests to 127.0.0.1:4747, so the remote host doesn't expose anything. It only needs a tunnel back to your machine and the hub token.
/wolfbud in any local Claude session. ssh -N -o ExitOnForwardFailure=yes -R 127.0.0.1:4747:127.0.0.1:4747 you@remote # or autossh -M 0 … to keep it up
Anything that makes 127.0.0.1:4747 on the remote reach 127.0.0.1:4747 on your machine as raw TCP works too, for example Tailscale (tailscale serve --tcp 4747 tcp://127.0.0.1:4747 on your machine, socat TCP-LISTEN:4747,bind=127.0.0.1,fork TCP:<your-machine>:4747 on the remote). An HTTP proxy that rewrites Host doesn't work, because the hub rejects any Host other than 127.0.0.1:4747.
jq '{token}' ~/.wolfbud/hub.json | ssh you@remote 'mkdir -p -m 700 ~/.wolfbud && umask 077 && cat > ~/.wolfbud/hub.json'
The token survives hub restarts, so you copy it once.
/wolfbud there. Don't set an ElevenLabs key on the remote: the hub on your machine already has one.A session counts as remote when it runs under SSH (SSH_CONNECTION) or as a Claude Code cloud session (CLAUDE_CODE_REMOTE=true). For a container you docker exec into, set WOLFBUD_REMOTE=1. If you SSH into your own machine and want it treated as local, set WOLFBUD_REMOTE=0. A remote session never starts a hub of its own. If it did, that hub would have no window and would take the port the tunnel needs when it reconnects.
If something is missing, a remote session tells you which step:
| What you see | Fix |
|---|---|
Can't reach the WolfBud hub from this remote session | The hub isn't running on your machine, or the tunnel is down: steps 1–2 |
this machine has no hub token | Step 3 |
The hub refused the token | The token is stale or wrong: step 3 again |
The status line reads WolfBud ✕ can't reach your machine's hub until the tunnel is back, and the mod reconnects by itself every 2s. /wolfbud status adds (remote session: through the tunnel to your machine) once it's connected.
In the window's session list, each session shows where it runs: local for this machine, or @host with a dashed outline for a remote one. Hover a session for the full text. The host name comes from WOLFBUD_HOST, else HOSTNAME, else the SSH server's address. The voice agent also hears which sessions are remote.
userConfig in .claude-plugin/plugin.json:
| Option | Default | |
|---|---|---|
api_key | $ELEVENLABS_API_KEY | The hub mints call tokens with it. Sensitive. Each /wolfbud hands it to the hub, which keeps the first non-empty one, so a session with a key repairs a hub started without one. |
The Chrome face is one Google Chrome app window on 127.0.0.1:4747. The port does not walk: the mic grant is per origin. The native app uses the same port and the same routes.
Env overrides: WOLFBUD_AGENT_ID (an agent to use as it is, never synced; else the one the hub set up), WOLFBUD_NODE (else node on PATH, used only to run the launcher), WOLFBUD_REMOTE (1 or 0: force remote or local, see Remote sessions), WOLFBUD_HOST (the name a remote session shows in the session list).
elevenlabs/agent.json (settings, tools) and elevenlabs/prompt.md (system prompt) are the source of truth, and every install syncs its user's own agent to them. The hub keeps the agent's id in ~/.wolfbud/agent.json with a hash of the definition. A hub whose definition hashes differently (an edit, or a plugin update that changed it) syncs again before the first call. So does one whose saved agent is gone (deleted, or in another account than the key's).
pnpm agent:sync syncs by hand. pnpm agent:sync --dry-run checks the definition (no em dashes: the agent would copy them) and prints what a sync sends; the pre-commit hook runs it. pnpm agent:simulate runs a simulated call, no mic needed.bridge/agent.mjs: plain REST calls, no SDK, so an install needs no packages. Client tools upsert by name, the agent is found or created by name, and an update sends only what the definition owns. The agent PATCH deep-merges, so a setting tried in the dashboard survives until it's codified here.window/src/call.ts: rename both or neither.deepseek-v41-flash (LLM) and eleven_v4_turbo (TTS). English agents are refused the v2.5 TTS models (eleven_flash_v2_5, eleven_turbo_v2_5).[laughing]); the window strips them from captions and the pane.Host and Origin values. Remote sessions reach it through a tunnel you open (see Remote sessions). Don't expose port 4747 on a public URL: the window routes would be reachable too.~/.wolfbud/hub.json holds the service token a mod uses to subscribe, and nothing else. The window URL holds a different key, which can enqueue a command for a short name and cannot pull an inbox; Chrome's command line shows it, so it stays the weakest. Subscribe returns a session token that pulls that session's inbox, acks, shows the window and ends the call. It cannot enqueue for every session.claude plugin validate mods/wolfbud
npx -y -p typescript@5 tsc -p mods/wolfbud
(cd mods/wolfbud && claude plugin test)
node --test mods/wolfbud/bridge/hub.test.mjs
pnpm window:typecheck && pnpm window:build
The mod tests stand a fake hub beneath the plugin (tests/wolfbud.test.tsx); the hub test runs the real server.mjs with two sessions and checks that a named send lands in one inbox only. The kit can't store a plugin's session.append, so the mid-turn note's success path is only exercised in a real session. Its fallback (queue the prompt) is tested.
hooks/register.tsx 714 lines1// WolfBud: one voice beside every Claude Code session.
2//
3// The hooks here watch this session and subscribe it to a hub that outlives
4// any one session (bridge/server.mjs on 127.0.0.1:4747). The hub serves one
5// Chrome window: the 3D wolf and an ElevenLabs voice agent. A prompt the agent
6// sends is queued for this session only; this mod pulls it and runs deliver().
7// The hub never calls Claude. If the hub is down, the session keeps working
8// and the pane says so.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, PluginOptions, Register } from 'claude-code'
12
13import type { WolfbudCall, WolfbudHub, WolfbudLine } from '../types'
14import { clip, describeTool, errorGist, fitTail, formatSnapshot, projectName, toolLabel, wrappedRows } from './activity'
15import type { ClaudeEvent, HubCall, HubCommand, PullResponse } from './events'
16
17const PANE = 'wolfbud'
18// Fixed origin: the mic grant is per origin, so the port does not walk.
19const PORT = 4747
20const ORIGIN = `http://127.0.0.1:${PORT}`
21const MAX_LINES = 80
22// The pane's rows besides the transcript: the call line, Claude's line, the two
23// margins and the buttons. A transcript line's text starts after its 8-cell
24// label and a 1-cell gap.
25const PANE_CHROME_ROWS = 5
26const LINE_INDENT = 9
27const USAGE = 'Usage: /wolfbud [call | end | window | hide | stop | status]'
28const LEAD = 'The user asked WolfBud, the voice assistant on a call beside this session, to pass this on:'
29const HUB_DOWN = 'WolfBud hub is down. This session keeps working.'
30const TUNNEL = `ssh -R ${PORT}:127.0.0.1:${PORT} <this host>`
31const REMOTE_NO_HUB =
32 "Can't reach the WolfBud hub from this remote session. The hub runs on your own machine: start it there first " +
33 `(/wolfbud in a local Claude session), then tunnel port ${PORT} back to it from your machine, e.g. \`${TUNNEL}\`. ` +
34 'See "Remote sessions" in the wolfbud README.'
35const REMOTE_NO_TOKEN =
36 'The hub answers through the tunnel, but this machine has no hub token. On your machine run ' +
37 "`jq '{token}' ~/.wolfbud/hub.json` and save the output here as ~/.wolfbud/hub.json, then /wolfbud again."
38const LOCAL_NO_TOKEN = 'The hub is up, but ~/.wolfbud/hub.json has no token. Restart the hub, then /wolfbud again.'
39const BAD_TOKEN = 'The hub refused the token in ~/.wolfbud/hub.json.'
40const REMOTE_BAD_TOKEN = `${BAD_TOKEN} Copy it again from the machine running the hub: \`jq '{token}' ~/.wolfbud/hub.json\`.`
41const REMOTE_READY = 'Remote session: connected to the hub on your machine through the tunnel.'
42const NO_WINDOW = 'No WolfBud window is open.'
43
44type Settings = { apiKey: string }
45type SendCommand = Extract<HubCommand, { type: 'send' }>
46type StopCommand = Extract<HubCommand, { type: 'stop' }>
47type HubResponse = { ok: boolean; status: number; text: string }
48
49const IDLE_CALL: WolfbudCall = { status: 'idle', mode: null, error: null }
50const IDLE_HUB: WolfbudHub = {
51 status: 'off',
52 error: null,
53 isRemote: false,
54 hasApiKey: false,
55 isWindowBuilt: false,
56 isWindowOpen: false,
57 sessionId: '',
58 sessionToken: '',
59 serviceToken: '',
60 name: '',
61}
62
63const hub = atom({ plugin: 'wolfbud', key: 'hub' } as const, IDLE_HUB)
64const call = atom({ plugin: 'wolfbud', key: 'call' } as const, IDLE_CALL)
65const lines = atom({ plugin: 'wolfbud', key: 'lines' } as const, [])
66const claude = atom({ plugin: 'wolfbud', key: 'claude' } as const, { isBusy: false, turnId: null })
67
68// Module variables reset on a reload. What must survive lives in $.state.
69let settings: Settings = { apiKey: '' }
70let pollTimer: { cancel: () => void } | null = null
71let pollGen = 0
72// Events wait here for one POST once the hook that saw them has returned.
73let eventBuffer: ClaudeEvent[] = []
74let flushTimer: { cancel: () => void } | null = null
75
76function readSettings(options: PluginOptions): Settings {
77 return { apiKey: typeof options.api_key === 'string' ? options.api_key.trim() : '' }
78}
79
80async function patchHub($: EngineInterface, patch: Partial<WolfbudHub>): Promise<void> {
81 await update($, hub, current => ({ ...current, ...patch }))
82 await refreshStatus($)
83}
84
85/**
86 * The pane folded into one status line: shown while this session is
87 * subscribed and the pane is closed, cleared while the pane is up.
88 */
89async function refreshStatus($: EngineInterface): Promise<void> {
90 const [b, c, { isBusy }, panes] = await Promise.all([read($, hub), read($, call), read($, claude), $.ui.panes()])
91 if (b.status === 'off' || panes.some(pane => pane.id === PANE)) {
92 $.ui.status(undefined)
93 return
94 }
95 if (b.status === 'down') {
96 $.ui.status(b.isRemote ? "WolfBud ✕ can't reach your machine's hub · /wolfbud status" : 'WolfBud ✕ hub down · /wolfbud to retry')
97 return
98 }
99 const who = b.name !== '' ? `${b.name} · ` : ''
100 $.ui.status(`WolfBud ${callLabel(c).text} · ${who}${isBusy ? 'Claude is working' : 'Claude is idle'} · /wolfbud to expand`)
101}
102
103async function openPane($: EngineInterface): Promise<void> {
104 await $.ui.open({ id: PANE, title: 'WolfBud' })
105 await refreshStatus($)
106}
107
108async function addLine($: EngineInterface, role: WolfbudLine['role'], text: string): Promise<void> {
109 await update($, lines, list => [...list, { id: (list.at(-1)?.id ?? 0) + 1, role, text }].slice(-MAX_LINES))
110}
111
112/**
113 * One request to the hub. The session token goes along once there is one;
114 * `/api/subscribe` alone takes the service token instead. Rejects on a
115 * network error, like `$.http.fetch`.
116 */
117async function hubFetch(
118 $: EngineInterface,
119 path: string,
120 init: { method?: 'GET' | 'POST'; body?: unknown; serviceToken?: string } = {},
121): Promise<HubResponse> {
122 const current = await read($, hub)
123 const headers: Record<string, string> = {}
124 if (init.body !== undefined) headers['content-type'] = 'application/json'
125 if (init.serviceToken !== undefined) headers['x-wolfbud-key'] = init.serviceToken
126 else if (current.sessionToken !== '') headers['x-wolfbud-session'] = current.sessionToken
127 const res = await $.http.fetch(`${ORIGIN}${path}`, {
128 method: init.method ?? (init.body === undefined ? 'GET' : 'POST'),
129 headers,
130 ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
131 })
132 return { ok: res.ok, status: res.status, text: res.text }
133}
134
135async function readServiceToken($: EngineInterface): Promise<string | null> {
136 const home = (await $.env.get('HOME')) ?? ''
137 if (home === '') return null
138 try {
139 const parsed = JSON.parse(await $.fs.read(`${home}/.wolfbud/hub.json`)) as { token?: unknown }
140 return typeof parsed.token === 'string' && parsed.token !== '' ? parsed.token : null
141 } catch {
142 return null
143 }
144}
145
146async function isHubUp($: EngineInterface): Promise<boolean> {
147 try {
148 return (await $.http.fetch(`${ORIGIN}/api/health`)).ok
149 } catch {
150 return false
151 }
152}
153
154/**
155 * Whether this Claude runs off the machine that owns the hub, window and mic.
156 * WOLFBUD_REMOTE=1 or 0 decides; unset, an SSH login or a Claude Code cloud
157 * session counts as remote. A container you exec into needs WOLFBUD_REMOTE=1.
158 */
159async function isRemoteSession($: EngineInterface): Promise<boolean> {
160 const flag = ((await $.env.get('WOLFBUD_REMOTE')) ?? '').trim().toLowerCase()
161 if (['1', 'true', 'yes'].includes(flag)) return true
162 if (['0', 'false', 'no'].includes(flag)) return false
163 const [ssh, cloud] = await Promise.all([$.env.get('SSH_CONNECTION'), $.env.get('CLAUDE_CODE_REMOTE')])
164 return Boolean(ssh) || cloud === 'true'
165}
166
167/** What the roster calls a remote session's machine: WOLFBUD_HOST, else HOSTNAME, else the SSH server's address. */
168async function remoteHost($: EngineInterface): Promise<string> {
169 const [named, hostname, ssh] = await Promise.all([$.env.get('WOLFBUD_HOST'), $.env.get('HOSTNAME'), $.env.get('SSH_CONNECTION')])
170 return (named || hostname || (ssh ?? '').split(' ')[2] || '').trim()
171}
172
173/** Marks the hub down with what to do about it, and says so once each time the reason changes. */
174async function markDown($: EngineInterface, reason = `${HUB_DOWN} /wolfbud tries again.`): Promise<void> {
175 const before = await read($, hub)
176 await patchHub($, { status: 'down', error: reason })
177 if (before.status !== 'down' || before.error !== reason) $.ui.toast(`WolfBud: ${reason}`)
178}
179
180/** What a failed connect left to say, for a command's answer. */
181async function downText($: EngineInterface): Promise<string> {
182 return (await read($, hub)).error ?? HUB_DOWN
183}
184
185/**
186 * Starts the hub if nothing is answering, then reads the token the hub wrote.
187 * A remote session never starts one: a hub there would have no window, and it
188 * would hold the port the tunnel needs once the tunnel comes back.
189 */
190async function ensureHub($: EngineInterface): Promise<boolean> {
191 const isRemote = await isRemoteSession($)
192 await patchHub($, { isRemote })
193 if (!(await isHubUp($))) {
194 if (isRemote) {
195 await markDown($, REMOTE_NO_HUB)
196 return false
197 }
198 const node = (await $.env.get('WOLFBUD_NODE')) || 'node'
199 const ran = await $.process.run([node, `${$.plugin.root}/bridge/launch.mjs`], { timeoutMs: 20_000 }).catch(() => null)
200 if (ran === null || ran.exitCode !== 0 || !(await isHubUp($))) {
201 await markDown($)
202 return false
203 }
204 }
205 const token = await readServiceToken($)
206 if (token === null) {
207 await markDown($, isRemote ? REMOTE_NO_TOKEN : LOCAL_NO_TOKEN)
208 return false
209 }
210 await patchHub($, { serviceToken: token })
211 return true
212}
213
214/** Hub up and this session subscribed to it, from whatever state we are in. */
215async function connect($: EngineInterface): Promise<boolean> {
216 return (await ensureHub($)) && subscribe($)
217}
218
219function stopPoll(): void {
220 pollGen += 1
221 pollTimer?.cancel()
222 pollTimer = null
223}
224
225function armPoll($: EngineInterface, gen: number, ms: number): void {
226 pollTimer?.cancel()
227 pollTimer = $.clock.after(ms, () => {
228 void poll($, gen)
229 })
230}
231
232function startPoll($: EngineInterface): void {
233 pollGen += 1
234 armPoll($, pollGen, 0)
235}
236
237/**
238 * One pull of this session's inbox. The hub holds an empty request until
239 * something is queued, so the loop mostly paces itself; the short gap after an
240 * answer keeps a hub that answers at once from being hammered.
241 */
242async function poll($: EngineInterface, gen: number): Promise<void> {
243 if (gen !== pollGen) return
244 const current = await read($, hub)
245 if (current.status === 'off') return
246 if (current.status !== 'ready') {
247 if (!(await connect($))) armPoll($, gen, 2000)
248 return
249 }
250 let delay = 250
251 try {
252 const res = await hubFetch($, '/api/session/commands')
253 if (gen !== pollGen) return
254 if (res.status === 401) {
255 // The hub forgot us (it restarted). subscribe() starts a fresh poll.
256 await subscribe($)
257 return
258 }
259 if (!res.ok) {
260 delay = 2000
261 } else {
262 const body = JSON.parse(res.text) as PullResponse
263 await applyCall($, body.call, body.isWindowOpen)
264 for (const line of body.lines) await addLine($, line.role, line.text)
265 for (const command of body.commands) await onCommand($, command)
266 }
267 } catch {
268 if (gen !== pollGen) return
269 await markDown($, current.isRemote ? REMOTE_NO_HUB : undefined)
270 delay = 2000
271 }
272 if (gen !== pollGen) return
273 armPoll($, gen, delay)
274}
275
276async function subscribe($: EngineInterface): Promise<boolean> {
277 const current = await read($, hub)
278 const sessionId = current.sessionId !== '' ? current.sessionId : crypto.randomUUID()
279 const [cwd, { isBusy }, envKey, host] = await Promise.all([
280 $.session.cwd(),
281 read($, claude),
282 $.env.get('ELEVENLABS_API_KEY'),
283 current.isRemote ? remoteHost($) : Promise.resolve(''),
284 ])
285 try {
286 const res = await hubFetch($, '/api/subscribe', {
287 serviceToken: current.serviceToken,
288 body: { sessionId, project: projectName(cwd), isBusy, isRemote: current.isRemote, host, apiKey: settings.apiKey || envKey || '' },
289 })
290 const body = res.ok
291 ? (JSON.parse(res.text) as { token?: string; name?: string; hasApiKey?: boolean; isWindowBuilt?: boolean; windows?: number })
292 : {}
293 if (typeof body.token !== 'string' || body.token === '') {
294 await markDown($, res.status === 401 ? (current.isRemote ? REMOTE_BAD_TOKEN : BAD_TOKEN) : undefined)
295 return false
296 }
297 if (current.isRemote && current.status !== 'ready') await addLine($, 'note', REMOTE_READY)
298 await patchHub($, {
299 status: 'ready',
300 sessionId,
301 sessionToken: body.token,
302 name: typeof body.name === 'string' ? body.name : projectName(cwd),
303 hasApiKey: Boolean(body.hasApiKey),
304 isWindowBuilt: Boolean(body.isWindowBuilt),
305 isWindowOpen: Number(body.windows ?? 0) > 0,
306 error: null,
307 })
308 startPoll($)
309 return true
310 } catch {
311 await markDown($, current.isRemote ? REMOTE_NO_HUB : undefined)
312 return false
313 }
314}
315
316async function postEvents($: EngineInterface, body: { events?: ClaudeEvent[]; snapshot?: string }): Promise<void> {
317 if ((await read($, hub)).status !== 'ready') return
318 try {
319 await hubFetch($, '/api/session/events', { body })
320 } catch {
321 // The next poll notices a hub that is gone.
322 }
323}
324
325async function flushEvents($: EngineInterface): Promise<void> {
326 flushTimer = null
327 const events = eventBuffer
328 eventBuffer = []
329 if (events.length > 0) await postEvents($, { events })
330}
331
332/** Queues an event for the hub. The POST runs once the hook that saw it has returned, one for a whole burst. */
333function report($: EngineInterface, event: ClaudeEvent): void {
334 eventBuffer.push(event)
335 flushTimer ??= $.clock.after(0, () => {
336 void flushEvents($)
337 })
338}
339
340async function ack($: EngineInterface, id: string, ok: boolean, message: string): Promise<void> {
341 try {
342 await hubFetch($, '/api/session/ack', { body: { id, ok, message } })
343 } catch {
344 // The voice tool times out on its own; the pane already has the line.
345 }
346}
347
348async function showWindow($: EngineInterface, withCall: boolean, isRetry = false): Promise<string> {
349 const current = await read($, hub)
350 if (current.status !== 'ready' && !(await connect($))) return downText($)
351 try {
352 const face = ((await $.env.get('WOLFBUD_FACE')) ?? '').trim().toLowerCase()
353 const res = await hubFetch($, '/api/session/window', { body: face === '' ? { call: withCall } : { call: withCall, face } })
354 // The hub forgot us (it restarted): subscribe again, once.
355 if (res.status === 401 && !isRetry) return (await connect($)) ? showWindow($, withCall, true) : downText($)
356 if (!res.ok) return 'WolfBud could not open the window.'
357 const body = JSON.parse(res.text) as { connected?: boolean }
358 if (body.connected) await patchHub($, { isWindowOpen: true })
359 const name = (await read($, hub)).name
360 const focus = name !== '' ? `, focused on ${name}` : ''
361 if (withCall) return `Calling WolfBud${focus}.`
362 return body.connected ? `WolfBud is up${focus}.` : 'Opening WolfBud.'
363 } catch {
364 await markDown($, (await read($, hub)).isRemote ? REMOTE_NO_HUB : undefined)
365 return downText($)
366 }
367}
368
369async function endCall($: EngineInterface): Promise<string> {
370 if ((await read($, hub)).status !== 'ready') return NO_WINDOW
371 try {
372 const res = await hubFetch($, '/api/session/call/end', { method: 'POST' })
373 const body = res.ok ? (JSON.parse(res.text) as { delivered?: boolean }) : {}
374 return body.delivered ? 'Ending the call.' : NO_WINDOW
375 } catch {
376 return HUB_DOWN
377 }
378}
379
380async function unsubscribe($: EngineInterface): Promise<void> {
381 stopPoll()
382 flushTimer?.cancel()
383 flushTimer = null
384 eventBuffer = []
385 if ((await read($, hub)).status === 'ready') {
386 try {
387 await hubFetch($, '/api/session/bye', { method: 'POST' })
388 } catch {
389 // Leaving locally still stands.
390 }
391 }
392 await update($, hub, () => IDLE_HUB)
393 await update($, call, () => IDLE_CALL)
394 await refreshStatus($)
395}
396
397/** The call as the hub reports it on every pull. A change of state is a note in the transcript. */
398async function applyCall($: EngineInterface, next: HubCall, isWindowOpen: boolean): Promise<void> {
399 const [before, current] = await Promise.all([read($, call), read($, hub)])
400 if (current.isWindowOpen !== isWindowOpen) await patchHub($, { isWindowOpen })
401 if (before.status === next.status && before.mode === next.mode && before.error === next.error) return
402 await update($, call, () => next)
403 await refreshStatus($)
404 if (next.status === 'live' && before.status !== 'live') await addLine($, 'note', 'Call started')
405 if (next.status !== 'live' && before.status === 'live') await addLine($, 'note', 'Call ended')
406 if (next.status === 'error' && next.error && before.error !== next.error) await addLine($, 'note', next.error)
407}
408
409async function onCommand($: EngineInterface, command: HubCommand): Promise<void> {
410 switch (command.type) {
411 case 'send':
412 await deliver($, command)
413 return
414 case 'stop':
415 await stopClaude($, command)
416 return
417 case 'snapshot':
418 await sendSnapshot($)
419 return
420 }
421}
422
423/** The transcript so far, posted back for the agent. No ack: the hub does not wait on it. */
424async function sendSnapshot($: EngineInterface): Promise<void> {
425 const [found, cwd, { isBusy }, { name }] = await Promise.all([$.session.messages(), $.session.cwd(), read($, claude), read($, hub)])
426 const messages = Array.isArray(found) ? found : []
427 await postEvents($, { snapshot: formatSnapshot(messages, { project: projectName(cwd), isBusy, name }) })
428}
429
430/** The agent's prompt for Claude: started, steered into the running turn, or queued. */
431async function deliver($: EngineInterface, request: SendCommand): Promise<void> {
432 const prompt = request.prompt.trim()
433 if (prompt === '') {
434 await ack($, request.id, false, 'The prompt was empty, so nothing was sent.')
435 return
436 }
437 const { isBusy } = await read($, claude)
438 let message: string | null = null
439
440 if (isBusy && request.when === 'now') {
441 // Joins the running turn: the model reads it at its loop's next top.
442 const note = `${LEAD}\n\n${prompt}\n\n(You are mid-task: take this into account from your next step.)`
443 const stored = await $.session
444 .append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
445 .catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
446 if (stored.deny === undefined) {
447 message = 'Delivered mid-task: Claude will read it at its next step.'
448 } else {
449 $.ui.log(`wolfbud: mid-turn note refused (${stored.deny}); queued instead`, { to: 'debug' })
450 }
451 }
452 if (message === null) {
453 // Resolves only once its turn starts, which may be after the current one,
454 // so the ack can't wait for it.
455 void $.prompt.submit({ text: `${LEAD}\n\n${prompt}` }).catch((error: unknown) => {
456 void addLine($, 'note', `Claude Code didn't take the prompt: ${error instanceof Error ? error.message : String(error)}`)
457 })
458 message = !isBusy
459 ? 'Sent: Claude is starting on it now.'
460 : request.when === 'now'
461 ? "Couldn't reach Claude mid-task, so it's queued: Claude starts on it as soon as it finishes the current task."
462 : 'Queued: Claude will start on it as soon as it finishes the current task.'
463 }
464
465 await addLine($, 'sent', request.summary.trim() || clip(prompt, 140))
466 await ack($, request.id, true, message)
467}
468
469async function stopClaude($: EngineInterface, request: StopCommand): Promise<void> {
470 const { isBusy, turnId } = await read($, claude)
471 if (!isBusy || turnId === null) {
472 await ack($, request.id, false, "Claude isn't running anything right now.")
473 return
474 }
475 try {
476 await $.turn.abort({ turnId })
477 await addLine($, 'note', `Stopped Claude${request.reason ? `: ${request.reason}` : ''}`)
478 await ack($, request.id, true, 'Stopped Claude.')
479 } catch (error) {
480 await ack($, request.id, false, `Couldn't stop Claude: ${error instanceof Error ? error.message : String(error)}`)
481 }
482}
483
484async function statusText($: EngineInterface): Promise<string> {
485 const [b, c] = await Promise.all([read($, hub), read($, call)])
486 const where = b.name !== '' ? `${b.name}, ` : ''
487 const hubLine =
488 b.status === 'ready'
489 ? `hub on ${ORIGIN}${b.isRemote ? ' (remote session: through the tunnel to your machine)' : ''}`
490 : `hub ${b.status}`
491 return [
492 `WolfBud: ${where}${hubLine}, window ${b.isWindowOpen ? 'open' : 'closed'}, call ${c.status}.`,
493 ...problems(b).map(problem => `- ${problem}`),
494 ].join('\n')
495}
496
497/** What stands between the person and a working call, each with its fix. */
498function problems(b: WolfbudHub): string[] {
499 if (b.status === 'down') return [b.error ?? HUB_DOWN]
500 if (b.status !== 'ready') return []
501 const found: string[] = []
502 if (!b.hasApiKey) found.push('No ElevenLabs API key: set ELEVENLABS_API_KEY (or the api_key option), then run /wolfbud again.')
503 if (!b.isWindowBuilt) {
504 const where = b.isRemote ? ' on the machine running the hub' : ''
505 found.push(`The window is not built: run \`pnpm window:build\` in the wolfbud-claude-mod repo${where}.`)
506 }
507 return found
508}
509
510function callLabel(c: WolfbudCall): { text: string; color?: string } {
511 switch (c.status) {
512 case 'live':
513 return { text: c.mode === 'speaking' ? '● on a call · talking' : '● on a call · listening', color: 'green' }
514 case 'connecting':
515 return { text: '◌ connecting…', color: 'yellow' }
516 case 'error':
517 return { text: '✕ call failed', color: 'red' }
518 default:
519 return { text: '○ not on a call' }
520 }
521}
522
523const ROLE_LABEL: Record<WolfbudLine['role'], { text: string; color?: string }> = {
524 user: { text: 'you', color: 'cyan' },
525 agent: { text: 'wolf', color: 'magenta' },
526 sent: { text: '→ claude', color: 'yellow' },
527 note: { text: '·' },
528}
529
530export const register: Register = (on, options) => {
531 settings = readSettings(options)
532
533 on('session.start', async ($, e, next) => {
534 await $.command.register({
535 name: 'wolfbud',
536 description: 'WolfBud: a voice coworker that watches this session and can message Claude',
537 argumentHint: '[call | end | window | hide | stop | status]',
538 })
539 // A reload dropped the poll. The hub is still up; subscribe the same session again.
540 if (e.isInteractive && (await read($, hub)).status !== 'off') await connect($)
541 return next(e)
542 })
543
544 on('command.run', { command: 'wolfbud' }, async ($, e) => {
545 const arg = e.args.trim().toLowerCase()
546 switch (arg) {
547 case '':
548 case 'open':
549 await openPane($)
550 return { text: await showWindow($, false) }
551 case 'call':
552 await openPane($)
553 return { text: await showWindow($, true) }
554 case 'end':
555 return { text: await endCall($) }
556 case 'window':
557 return { text: await showWindow($, false) }
558 case 'hide':
559 await $.ui.close({ id: PANE })
560 return { text: 'WolfBud folded into the status line. /wolfbud brings the pane back.' }
561 case 'stop':
562 await unsubscribe($)
563 return { text: 'This session left WolfBud. Other sessions keep the window.' }
564 case 'status':
565 return { text: await statusText($) }
566 default:
567 return { text: USAGE }
568 }
569 })
570
571 on('prompt.submit', async ($, e, next) => {
572 const from =
573 e.origin.kind === 'plugin'
574 ? e.origin.name === 'wolfbud'
575 ? 'wolfbud'
576 : null
577 : ['composer', 'bridge', 'sdk'].includes(e.origin.kind)
578 ? 'user'
579 : null
580 if (from !== null && e.text.trim() !== '') {
581 const text = from === 'wolfbud' ? e.text.replace(LEAD, '').trim() : e.text
582 report($, { kind: 'prompt', at: await $.clock.now(), text: clip(text, 1500), from })
583 }
584 return next(e)
585 })
586
587 on('turn.start', async ($, e, next) => {
588 await update($, claude, () => ({ isBusy: true, turnId: e.turnId }))
589 await refreshStatus($)
590 report($, { kind: 'turn-start', at: await $.clock.now() })
591 return next(e)
592 })
593
594 on('tool.call', async ($, e, next) => {
595 const ran = await next(e)
596 const { tool, tool_use_id: _id, agentId, consent: _consent, ...input } = e as unknown as Record<string, unknown>
597 const name = String(tool)
598 const status = ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok'
599 const error = errorGist(ran.deny ?? (ran.isError === true ? ran.text : undefined))
600 report($, {
601 kind: 'tool',
602 at: await $.clock.now(),
603 tool: toolLabel(name),
604 detail: describeTool(name, input),
605 status,
606 ...(error !== undefined ? { error } : {}),
607 ...(agentId !== undefined ? { isSubagent: true as const } : {}),
608 })
609 return ran
610 })
611
612 on('turn.complete', async ($, e, next) => {
613 if (e.agentId === undefined) {
614 await update($, claude, () => ({ isBusy: false, turnId: null }))
615 await refreshStatus($)
616 report($, {
617 kind: 'turn-complete',
618 at: await $.clock.now(),
619 answer: e.answer.slice(0, 4000),
620 reason: e.reason,
621 durationMs: e.durationMs,
622 })
623 }
624 return next(e)
625 })
626
627 on('classic.Notification', async ($, e, next) => {
628 report($, { kind: 'notification', at: await $.clock.now(), message: clip(e.message, 300), type: e.notification_type })
629 return next(e)
630 })
631
632 on('session.end', async ($, e, next) => {
633 // /clear is a new session. Either way this subscription ends; other Claudes stay.
634 await update($, claude, () => ({ isBusy: false, turnId: null }))
635 await unsubscribe($)
636 return next(e)
637 })
638
639 // The person closing the pane (its mark or a key) folds it into the status line too.
640 on('ui.close', async ($, e, next) => {
641 const closed = await next(e)
642 if (e.id === PANE) await refreshStatus($)
643 return closed
644 })
645
646 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
647 const { Box, Text, Button } = $.ui.resolve(e)
648 const [b, c, list, { isBusy }] = await Promise.all([read($, hub), read($, call), read($, lines), read($, claude)])
649 const found = problems(b)
650 // The terminal clips a pane's tree from the bottom, so keep the newest lines
651 // that fit beside the header and buttons, and drop the oldest off the top.
652 const { bodyColumns, scroll } = e.props
653 const room = scroll.bodyRows - PANE_CHROME_ROWS - found.reduce((rows, problem) => rows + wrappedRows(problem, bodyColumns), 0)
654 const shown =
655 e.surface === 'terminal'
656 ? fitTail(list, Math.max(1, room), bodyColumns - LINE_INDENT)
657 : list.slice(-Math.max(3, (e.viewport?.rows ?? 24) - 10))
658 const status = callLabel(c)
659 const isOnCall = c.status === 'live' || c.status === 'connecting'
660 const who = b.name !== '' ? `${b.name} · ` : ''
661
662 return (
663 <Box flexDirection="column">
664 <Box flexDirection="row" gap={1}>
665 <Text bold>WolfBud</Text>
666 <Text color={status.color} dimColor={status.color === undefined}>
667 {status.text}
668 </Text>
669 </Box>
670 <Text dimColor>
671 {b.status === 'down'
672 ? b.isRemote
673 ? "can't reach your machine's hub, session still working"
674 : 'hub down, session still working'
675 : `${who}${b.isRemote ? 'remote · ' : ''}${isBusy ? 'Claude is working' : 'Claude is idle'}`}
676 </Text>
677 {found.map(problem => (
678 <Text color="yellow" wrap="wrap">
679 {problem}
680 </Text>
681 ))}
682 <Box flexDirection="column" marginTop={1}>
683 {shown.length === 0 && (
684 <Text dimColor wrap="wrap">
685 Call WolfBud to talk through what Claude is doing. What you agree on gets sent to Claude, and shows up here.
686 </Text>
687 )}
688 {shown.map(line => (
689 <Box key={`line-${line.id}`} flexDirection="row" gap={1}>
690 <Box width={8} flexShrink={0}>
691 <Text color={ROLE_LABEL[line.role].color} dimColor={line.role === 'note'}>
692 {ROLE_LABEL[line.role].text}
693 </Text>
694 </Box>
695 <Text wrap="wrap" dimColor={line.role === 'note'}>
696 {line.text}
697 </Text>
698 </Box>
699 ))}
700 </Box>
701 <Box flexDirection="row" gap={1} marginTop={1}>
702 {isOnCall ? (
703 <Button key="end" label="End call" hotkey="e" onPress={() => endCall($)} />
704 ) : (
705 <Button key="call" label="Call WolfBud" hotkey="c" variant="primary" onPress={() => showWindow($, true)} />
706 )}
707 <Button key="window" label="Window" hotkey="w" onPress={() => showWindow($, false)} />
708 <Button key="hide" label="Hide" hotkey="h" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
709 </Box>
710 </Box>
711 )
712 })
713}
714hooks/activity.ts 173 lines1// Pure helpers: how Claude's activity reads to the voice agent, how the
2// bridge's stdout splits into messages, and how the pane's transcript fits its
3// rows. No `$` here, so tests call them bare.
4
5import type { SessionMessage } from 'claude-code'
6
7/** Collapses whitespace and cuts to `max` characters with an ellipsis. */
8export function clip(text: string, max: number): string {
9 const flat = text.replace(/\s+/g, ' ').trim()
10 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
11}
12
13/** The last two segments of a path: enough to say which file, short enough to speak. */
14export function shortPath(path: string): string {
15 return path.split('/').filter(Boolean).slice(-2).join('/')
16}
17
18export function projectName(cwd: string): string {
19 return cwd.split('/').filter(Boolean).at(-1) ?? cwd
20}
21
22function text(input: Record<string, unknown>, key: string): string {
23 const value = input[key]
24 return typeof value === 'string' ? value : ''
25}
26
27/** One line saying what a tool call was about, from its name and arguments. */
28export function describeTool(tool: string, input: Record<string, unknown>): string {
29 switch (tool) {
30 case 'Bash':
31 case 'PowerShell':
32 return clip(text(input, 'description') || text(input, 'command'), 140)
33 case 'Read':
34 case 'Write':
35 case 'Edit':
36 case 'MultiEdit':
37 case 'NotebookEdit':
38 return shortPath(text(input, 'file_path') || text(input, 'notebook_path'))
39 case 'Grep':
40 case 'Glob':
41 return clip(`${text(input, 'pattern')} ${text(input, 'path') ? `in ${shortPath(text(input, 'path'))}` : ''}`, 120)
42 case 'WebFetch':
43 return clip(text(input, 'url'), 120)
44 case 'WebSearch':
45 return clip(text(input, 'query'), 120)
46 case 'Agent':
47 case 'Task':
48 return clip(text(input, 'description') || text(input, 'subagent_type'), 120)
49 case 'Skill':
50 return clip(text(input, 'skill') || text(input, 'command'), 80)
51 case 'TodoWrite': {
52 const todos = Array.isArray(input.todos) ? (input.todos as Array<Record<string, unknown>>) : []
53 const active = todos.find(todo => todo.status === 'in_progress')
54 const done = todos.filter(todo => todo.status === 'completed').length
55 return clip(`${done}/${todos.length} done${active ? `, now: ${String(active.content ?? '')}` : ''}`, 140)
56 }
57 default: {
58 const first = Object.values(input).find(value => typeof value === 'string' && value.length > 0)
59 return typeof first === 'string' ? clip(first, 100) : ''
60 }
61 }
62}
63
64/** A tool's display name: `mcp__server__tool` reads as `server tool`. */
65export function toolLabel(tool: string): string {
66 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
67 return mcp ? `${mcp[1]} ${mcp[2]}` : tool
68}
69
70/** The tail of an error, where test runners and compilers put their summary. */
71export function errorGist(message: string | undefined): string | undefined {
72 if (!message) return undefined
73 const flat = message.replace(/\s+/g, ' ').trim()
74 return flat.length > 280 ? `…${flat.slice(-279)}` : flat
75}
76
77const SNAPSHOT_MESSAGES = 14
78const SNAPSHOT_BUDGET = 6000
79
80/**
81 * The "[session snapshot]" the agent gets as a call starts: the session's
82 * latest messages, newest last, each cut short, within a fixed budget.
83 */
84export function formatSnapshot(
85 messages: readonly SessionMessage[],
86 info: { project: string; isBusy: boolean; name?: string },
87): string {
88 const rows: string[] = []
89 for (const message of messages.slice(-SNAPSHOT_MESSAGES)) {
90 if (message.role === 'user') {
91 // Tool results ride on user rows; they are summarized on the call that made them.
92 if (message.text.trim() !== '') rows.push(`User: ${clip(message.text, 500)}`)
93 continue
94 }
95 const steps = message.toolUses
96 .map(use => {
97 const detail = describeTool(use.tool, use.input)
98 const mark = use.isError ? ' (failed)' : ''
99 return `${toolLabel(use.tool)}${detail ? ` ${detail}` : ''}${mark}`
100 })
101 .join('; ')
102 const said = message.text.trim() === '' ? '' : clip(message.text, 700)
103 if (said || steps) rows.push(`Claude: ${said}${said && steps ? ' ' : ''}${steps ? `[steps: ${steps}]` : ''}`)
104 }
105
106 let body = rows.join('\n')
107 if (body.length > SNAPSHOT_BUDGET) body = `…${body.slice(-SNAPSHOT_BUDGET)}`
108 const status = info.isBusy ? 'Claude is working on a task right now.' : 'Claude is idle, waiting for the user.'
109 const who = info.name ? `Session ${info.name}. ` : ''
110 return [
111 `[session snapshot] ${who}Project: ${info.project}. ${status}`,
112 body === '' ? 'Nothing has happened in this session yet.' : `Latest conversation between the user and Claude, oldest first:\n${body}`,
113 ].join('\n')
114}
115
116/**
117 * Rows `text` takes on the terminal wrapped at `width` cells: whole words while
118 * they fit, a word wider than a row broken across rows. Counts code points, so
119 * it reads a wide glyph as one cell.
120 */
121export function wrappedRows(text: string, width: number): number {
122 const cols = Math.max(1, Math.floor(width))
123 let rows = 0
124 for (const paragraph of text.split('\n')) {
125 rows += 1
126 let used = 0
127 for (const word of paragraph.split(' ')) {
128 const cells = [...word].length
129 if (used > 0 && used + 1 + cells <= cols) {
130 used += 1 + cells
131 continue
132 }
133 if (used > 0) rows += 1
134 const spill = Math.max(0, Math.ceil(cells / cols) - 1)
135 rows += spill
136 used = cells - spill * cols
137 }
138 }
139 return rows
140}
141
142/**
143 * The newest items that fit in `rows`, oldest dropped first, as a log pinned to
144 * its bottom shows them. When even the newest is taller, it alone, its text cut
145 * from the front (see `tailText`).
146 */
147export function fitTail<T extends { text: string }>(items: readonly T[], rows: number, width: number): T[] {
148 const shown: T[] = []
149 let used = 0
150 for (let i = items.length - 1; i >= 0; i -= 1) {
151 const item = items[i]!
152 const height = wrappedRows(item.text, width)
153 if (used + height > rows) {
154 if (shown.length === 0) shown.push({ ...item, text: tailText(item.text, rows, width) })
155 break
156 }
157 shown.unshift(item)
158 used += height
159 }
160 return shown
161}
162
163/** `text` cut from the front, behind an ellipsis, until it wraps into `rows` rows of `width` cells. */
164export function tailText(text: string, rows: number, width: number): string {
165 if (wrappedRows(text, width) <= rows) return text
166 const chars = [...text]
167 for (let keep = Math.max(1, rows * Math.max(1, Math.floor(width)) - 1); keep > 0; keep -= 1) {
168 const tail = `…${chars.slice(-keep).join('').trimStart()}`
169 if (wrappedRows(tail, width) <= rows) return tail
170 }
171 return '…'
172}
173hooks/events.ts 66 lines1// What the mod, the hub (bridge/server.mjs) and the window (window/src)
2// say to each other. Plain types with no imports, so the window imports them
3// too and the three halves can't drift.
4
5export type CallStatus = 'idle' | 'connecting' | 'live' | 'error'
6
7export type VoiceMode = 'speaking' | 'listening'
8
9/** One thing that happened in the Claude Code session, as the window hears it. */
10export type ClaudeEvent =
11 | { kind: 'prompt'; at: number; text: string; from: 'user' | 'wolfbud' }
12 | { kind: 'turn-start'; at: number }
13 | {
14 kind: 'tool'
15 at: number
16 tool: string
17 detail: string
18 status: 'ok' | 'error' | 'denied'
19 error?: string
20 isSubagent?: true
21 }
22 | {
23 kind: 'turn-complete'
24 at: number
25 answer: string
26 reason: 'answer' | 'aborted' | 'refusal' | 'error'
27 durationMs: number
28 }
29 | { kind: 'notification'; at: number; message: string; type: string }
30
31/** One subscribed Claude, as the window's roster draws it. */
32export type RosterRow = {
33 id: string
34 name: string
35 project: string
36 isBusy: boolean
37 badge: number
38 /** Runs off the hub's machine, through a tunnel. */
39 isRemote: boolean
40 /** Where a remote session runs, as its mod names it. Empty when local or unknown. */
41 host: string
42}
43
44/** A command the hub holds until the matching mod pulls it. */
45export type HubCommand =
46 | { id: string; type: 'send'; prompt: string; when: 'now' | 'after_current'; summary: string }
47 | { id: string; type: 'stop'; reason: string }
48 | { id: string; type: 'snapshot' }
49
50/** Something said on the call, queued for every subscribed pane. */
51export type HubLine = { role: 'user' | 'agent'; text: string }
52
53/** The one call, as the window last reported it. */
54export type HubCall = { status: CallStatus; mode: VoiceMode | null; error: string | null }
55
56/** What a pull of a session's inbox answers: its queue, plus the facts as they stand now. */
57export type PullResponse = {
58 commands: HubCommand[]
59 lines: HubLine[]
60 call: HubCall
61 isWindowOpen: boolean
62}
63
64/** What the hub asks of the window, over SSE. */
65export type WindowCommand = 'start-call' | 'end-call' | 'raise' | 'superseded'
66types/index.d.ts 51 lines1/** The shared hub (bridge/server.mjs) and this session's subscription to it. */
2export type WolfbudHub = {
3 /** `off`: not started or stopped here. `ready`: subscribed. `down`: wanted, but the hub isn't answering. */
4 status: 'off' | 'ready' | 'down'
5 error: string | null
6 /** This Claude runs off the hub's machine (SSH, a container): it reaches the hub through a tunnel and never launches one. */
7 isRemote: boolean
8 hasApiKey: boolean
9 isWindowBuilt: boolean
10 isWindowOpen: boolean
11 /** Stable across a reload. A new session mints another. */
12 sessionId: string
13 /** Pulls this session's inbox only. Not the key that can enqueue for every session. */
14 sessionToken: string
15 /** From ~/.wolfbud/hub.json. Subscribes. */
16 serviceToken: string
17 /** Short name the voice uses (`auth`, `auth-2`). */
18 name: string
19}
20
21/** The one voice call, as the window reports it. Shared by every subscription. */
22export type WolfbudCall = {
23 status: 'idle' | 'connecting' | 'live' | 'error'
24 mode: 'speaking' | 'listening' | null
25 error: string | null
26}
27
28/** A row of the pane's transcript: what was said, and what went to Claude. */
29export type WolfbudLine = {
30 id: number
31 role: 'user' | 'agent' | 'sent' | 'note'
32 text: string
33}
34
35/** Whether Claude is mid-turn, and that turn's id (for a stop). */
36export type WolfbudClaude = {
37 isBusy: boolean
38 turnId: string | null
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 wolfbud: {
44 hub: WolfbudHub
45 call: WolfbudCall
46 lines: WolfbudLine[]
47 claude: WolfbudClaude
48 }
49 }
50}
51