SLOPSHOPPER

wolfbud

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

newpaneguardcommandtoaststatus
★ 5v1.2.1no licenseupdated 2026-10-08leopiney/wolfbud-claude-mod/mods/wolfbud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wolfbud
│ ┃ WolfBud ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ WolfBud ○ not on a call │ wolfbud │ │ ┃ hub down, session still working ⏺ Read(src/auth.ts) │ WolfBud: WolfBud hub is down. This session │ │ ┃ WolfBud hub is down. This session keeps ⎿ Read 6 lines │ keeps working. /wolfbud tries again. │ │ ┃ working. /wolfbud tries again. ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Call WolfBud to talk through what Claude is ⏺ Bash(bun test) │ ┃ doing. What you agree on gets sent to ⎿ 3 pass, 1 fail │ ┃ Claude, and shows up here. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Call WolfBud ] [ Window ] [ Hide ] │ ✻ Worked for 42s · done 4:20 PM │ │ › /wolfbud │ ⎿ wolfbud: WolfBud hub is down. This session keeps working. /wolfb │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · WolfBud
WolfBud ○ not on a call hub down, session still working WolfBud hub is down. This session keeps working. /wolfbud tries again. Call WolfBud to talk through what Claude is doing. What you agree on gets sent to Claude, and shows up here. [ Call WolfBud ] [ Window ] [ Hide ]
README

wolfbud

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.

  • One window. A Chrome app window with its own profile at ~/.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.
  • Many sessions, one call. Each /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.
  • What the agent hears: a snapshot of the focused session when the call starts, rolling [claude activity] updates, [claude event] for that session, and [session event] when another subscribed Claude finishes or waits on permission.
  • What the agent can do: wolfbud_send_to_claude (optional session) turns what you decided into a prompt in that session:
  • Claude idle: a new turn starts.
  • Claude busy, now: a note goes into the running turn.
  • Claude busy, 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.

Install

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:

  1. Commit. The pre-commit hook bumps 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.
  2. Push. Installs pick it up with 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

Use

/wolfbudsubscribe this session, open the pane, and show the one window
/wolfbud callsame, focus this session, and start the call if it is not already live
/wolfbud endhang up the one call. Other subscriptions stay
/wolfbud windowraise the window and focus this session
/wolfbud statushub, window, call, and anything missing
/wolfbud hideclose the pane and fold it into one status line. /wolfbud unfolds it
/wolfbud stopunsubscribe 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.

Remote sessions

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.

  1. Start the hub on your machine first. Run /wolfbud in any local Claude session.
  2. Tunnel port 4747 from the remote back to your machine. From your machine:
   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.

  1. Copy the hub token into the remote's hub config. Copy only the token, not the window key:
   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.

  1. Install the mod on the remote and run /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 seeFix
Can't reach the WolfBud hub from this remote sessionThe hub isn't running on your machine, or the tunnel is down: steps 1–2
this machine has no hub tokenStep 3
The hub refused the tokenThe 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.

Options

userConfig in .claude-plugin/plugin.json:

OptionDefault
api_key$ELEVENLABS_API_KEYThe 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).

Changing the agent

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.
  • The sync is 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.
  • Tool names are the contract with window/src/call.ts: rename both or neither.
  • Renaming the agent creates a new one; delete the old one in the dashboard.
  • Models: 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).
  • The voice is from the Voice Library and allows free users. Agents use it by id, so no account has to add it first.
  • v4 can speak inline audio tags ([laughing]); the window strips them from captions and the pane.

Security

  • The hub listens on 127.0.0.1 only and rejects other 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.
  • Three keys. ~/.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.
  • The agent requires signed tokens, so its id alone can't start a call.
  • The ElevenLabs key stays in the hub process and never reaches the face, browser or native app.
  • Prompts reach Claude framed as coming from the wolfbud plugin ("The user asked WolfBud … to pass this on"), and the pane lists each one.
  • If the hub is down, the Claude session keeps working. The pane says so.

Development

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.

Source 4 files
hooks/register.tsx 714 lines
1// 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}
714
hooks/activity.ts 173 lines
1// 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}
173
hooks/events.ts 66 lines
1// 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'
66
types/index.d.ts 51 lines
1/** 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