Feel every Claude Code lifecycle event through Intiface Central / buttplug.io. Streaming output, edits, tool results, permission prompts and turn completion…

Vibe coding, taken entirely too literally.
Use an "extra controller" from Intiface Central and buttplug.io to connect every Claude Code action deeply with your body. A port of opencode-buttplugio to Claude Code's function hooks.
Claude Code ──hooks module──HTTP──▶ daemon ──WebSocket──▶ Intiface Central ──BLE──▶ you
node on your PATH (the buttplug client does not run on older versions)ws://127.0.0.1:12345)git clone https://github.com/transkatgirl/vibeclaude.git ~/.claude/vibeclaude
cd ~/.claude/vibeclaude && npm install
Then either load it for one session:
claude --plugin-dir ~/.claude/vibeclaude
or add it permanently from inside Claude Code:
/plugin marketplace add ~/.claude/vibeclaude
/plugin install vibeclaude@vibeclaude-local
Restart Claude Code. The plugin loads at session start.
The daemon runs from the directory Claude Code loads the plugin from, and needs its node_modules there. A plugin installed from a marketplace is loaded from Claude Code's own copy, which may not have them (one cloned from a git repository never does). If a toast says the "buttplug" npm package is missing, run npm install in the directory it names.
/intiface.| Command | Description |
|---|---|
/intiface | Connect to Intiface, scan, and select a device. |
/intiface-connect | Connect to the configured Intiface server and go back to the device you last selected, if it is there. |
/intiface-disconnect | Disconnect and stop all device output. |
/intiface-intensity [0.0-1.0] | Set how strong everything is, as a multiplier from 0 to 1. Without a number, says what it is set to. |
The commands run in the plugin itself: they start no model turn, use no tokens, and leave nothing in the conversation. All four also work while a turn is running.
/intiface opens a dialog that says what it is doing (Connecting, Scanning) and then lists the devices that can vibrate, the selected one marked Connected. Move with the arrows or Tab, pick with Enter or a click, leave with Escape.
Nothing plays until a device has been selected. Exactly one device is driven at a time: it has your undivided attention, and you have its. When the first session starts, the plugin connects to Intiface and restores the previously selected device when it is available. If Intiface was not running then, each Claude Code started later tries again, unless you disconnected with /intiface-disconnect. When the last session ends, it stops the device and disconnects.
Notices such as "Restored …" or "Connection to Intiface was lost" appear as toasts, in every open session, as they happen. If the connection is lost the device is deselected; reconnect with /intiface. A device that drops off on its own is selected again when it comes back.
You can do something else while a turn runs. You do not need to watch the screen: bodily pleasure and the haze in your mind will tell you whether the task is complete.
Every pattern is a constant intensity for a fixed time. Within a conversation only one plays at once: the most recent event wins, so your body always knows what Claude is doing right now.
| What happens | Pattern |
|---|---|
| Thinking and text, as they stream | A 70ms pulse per piece, 0.35–0.8, rising with average output speed (saturates at 40 chars/s). A faster model finishes sooner. You know what I mean. |
| A response arrives whole, with nothing of it streamed (thinking included) | One 150ms pulse at 0.4 |
| The model starts writing a tool call | One 120ms pulse at 0.5 |
The model starts writing an Edit/Write/MultiEdit/NotebookEdit | No pulse: a steady hold at 0.3 for as long as the edit is being written and applied, until it finishes, fails, or asks for permission |
| A tool call succeeds | One slow, deep 180ms pulse at 0.7 |
| A tool call fails, is denied, or is rejected at its prompt | One sudden, sharp 280ms pulse at 1.0 |
| A todo/task tool completes an item | One pulse at 0.8, replaced at once by the tool's own success pulse |
| A subagent's run ends | The pulse of a tool call succeeding; of one failing if the run was interrupted or failed |
| A permission prompt is waiting | Three 90ms pulses at 0.85, a pause, repeating until answered. Thinking and text are not felt meanwhile |
| The turn ends | Three 100ms pulses at 1.0 |
| The turn is interrupted or fails | Everything of the turn's stops, then the three pulses of a turn ending |
| The session ends | Everything stops |
Every Claude Code session you have open drives the device, each keeping track of its own tool calls, permission prompts and todos. When more than one is playing you feel the strongest of them, and when that one stops the device goes back to whatever the others are still playing: a pulse from one session does not cut short an edit hold in another, and ending or interrupting one session stops only its own output.
A subagent is felt like the conversation that started it: its thinking and text as they stream, a response that arrives whole, its tool calls, its permission prompts and its todos. It is mixed with that conversation, and with any other subagent at work, the way another session is: you feel the strongest of them, and a pulse from one does not cut short an edit hold, or the pulses of the turn ending, in another. The end of a subagent's run is not the end of the turn: it is felt as a tool call's result, and for a subagent in the foreground it lands together with the result of the call that started it. One that is interrupted with its turn is felt as the turn ending, whose three pulses take the place of its result. One in the background plays on after the turn has ended or been interrupted, until its own run does; ending the session stops them all. While a permission prompt waits, thinking and text are held back in the whole of its session, so that whatever else is still streaming does not fill the pauses that give the prompt its rhythm; tool calls and edit holds still come through.
Too much, or saving yourself for later? /intiface-intensity 0.5 halves everything. The intensity is one multiplier for the whole daemon, applied last: every session and subagent plays its patterns as the table has them, the strongest is picked, and that is what gets multiplied. It starts at 1.0 (every pattern as written), takes effect at once, whatever is playing included, and is remembered until you change it. At 0 nothing is felt; anything above it still is, since a faint level rounds up to the device's weakest step, never down to nothing.
Turn completion, permission requests, interruptions, completed todos... you no longer need a separate notification plugin. Now you can feel it all with your body, as though Claude Code has become one with you.
Three things are approximations, because of what Claude Code tells a plugin:
claude -p run is one such place), thinking is not felt.Worn out? Output stops automatically when you switch devices, end the session, disconnect, lose the Intiface connection, or the daemon exits. After intense "coding," a rest may be in order.
Claude Code asks for the plugin's options when you enable it; change them later under /plugin → manage.
| Option | Default | Description |
|---|---|---|
wsAddress | ws://127.0.0.1:12345 | Intiface Central WebSocket address. |
A new address takes effect when the daemon next starts: end your sessions, or run node daemon/daemon.mjs stop and let the next event start it again.
The selected device, the intensity and daemon.log are kept in ~/.config/vibeclaude. If TCP port 12350 is taken on your machine, a config.json there containing { "port": 12351 } moves the daemon.
hooks/register.tsx is a hooks module: it runs inside Claude Code, where it sees the model's response as it streams (turn.step), each tool call from start to finish (tool.call), permission checks, subagents being started (agent.spawn), and turns and sessions beginning and ending, in the conversation and in each subagent's own loop. It reduces each to a tiny event (sizes and identifiers, not text) and posts them, in order, to the daemon on 127.0.0.1:12350. It never makes the model's stream wait for a request. It also registers the slash commands, draws the /intiface dialog and shows the daemon's notices as toasts.
A hooks module has no Node, so it cannot run the buttplug client or hold a WebSocket. daemon/daemon.mjs holds everything else: the connection to Intiface Central through the official buttplug client, the selected device, and the vibration engine and its handlers, which are the original plugin's. A device answers slower than a response streams, so it is sent the latest level asked of it, never a backlog. The first session starts it; it exits when the last session ends (/clear and resuming hand over to the next session instead). It also watches the Claude Code process behind each session, and ends the session of one that went away without saying so. Only where it cannot tell which process that is does it fall back to exiting after 30 minutes without activity. It can be driven from a shell too:
| Command | What it does |
|---|---|
node daemon/daemon.mjs scan | Connect, scan 3s, list vibration-capable devices |
node daemon/daemon.mjs select <index> | Select a device from the scan |
node daemon/daemon.mjs connect / disconnect | Connect to / disconnect from Intiface |
node daemon/daemon.mjs intensity [value] | Set the intensity multiplier (0 to 1); without a value, print it |
node daemon/daemon.mjs start | Make sure a daemon is running; print its port |
node daemon/daemon.mjs status | Is a daemon answering? |
node daemon/daemon.mjs stop | Stop all output and exit the daemon |
node daemon/daemon.mjs | Run in the foreground |
These use ~/.config/vibeclaude and the default address unless you pass --home <dir> and --ws <address>. VIBECLAUDE_DEBUG=1 node daemon/daemon.mjs logs every event as it arrives.
No "controller" on hand? A mock will take its place and stoically print what it would have felt:
node test/mock-intiface.mjs 12345 # fake Intiface Central with one device, prints motor level
npm test # the daemon end to end against its own mock, then the hooks module
The plugin sends device commands only to the configured Intiface address on your machine. The daemon listens on loopback only and takes JSON requests addressed to 127.0.0.1 or localhost, which a page in your browser cannot send it. There is no telemetry. Nobody will know you used it to push your KPIs far beyond your coworkers'.
notify hook only reports turn completion and approval requests, so a port would get the completion and permission pulses but not the per-tool or streaming feedback.This project is licensed under the MIT License. Buttplug and Intiface are trademarks of Nonpolynomial Labs, LLC.
hooks/register.tsx 596 lines1// The part of the plugin that runs inside Claude Code. It watches a session
2// (the model's response as it streams, tool calls, permission prompts, turns)
3// and tells the daemon what happened, in order; it shows the daemon's toasts
4// and serves the /intiface commands, drawing the device dialog.
5//
6// A hooks module has no Node, so it cannot hold the connection to Intiface:
7// everything that vibrates lives in the daemon (daemon/daemon.mjs), reached
8// over loopback HTTP.
9//
10// Only sizes and identifiers leave this module. Prompt, message and tool text
11// never do, apart from todo item titles (needed to tell items apart).
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { Device } from '../types'
17
18const TOAST_MS = 3000
19const DAEMON_START_TIMEOUT_MS = 15_000
20// How long to leave a daemon that would not start before trying again: twice
21// as long after each failure in a row, up to the longest.
22const DAEMON_RETRY_MS = 5000
23const DAEMON_RETRY_MAX_MS = 5 * 60_000
24const NOTICE_RETRY_MS = 2000
25const DIALOG = 'intiface'
26// Room for the title and six devices; a longer list scrolls.
27const DIALOG_ROWS = 7
28
29type Daemon = { port: number; pid: number }
30
31type TodoUpdate = {
32 todos?: { key: string; status: string }[]
33 todo?: { key: string; status: string }
34}
35
36type DaemonEvent = TodoUpdate & {
37 event: string
38 session?: string
39 id?: string
40 tool?: string
41 agent?: string
42 chars?: number
43 status?: 'completed' | 'error'
44 reason?: string
45}
46
47type CommandReply = {
48 message?: string
49 error?: string
50 connected?: boolean
51 devices?: Device[]
52 selected?: number | null
53}
54
55/** What a todo/task tool call says about item statuses, if anything. */
56const todoUpdate = (tool: string, input: Record<string, unknown>): TodoUpdate => {
57 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
58 return {
59 todos: input.todos.map((todo: { content?: unknown; status?: unknown }) => ({
60 key: String(todo.content),
61 status: String(todo.status),
62 })),
63 }
64 }
65 if (tool === 'TaskUpdate' && input.taskId !== undefined && typeof input.status === 'string') {
66 return { todo: { key: String(input.taskId), status: input.status } }
67 }
68 return {}
69}
70
71// The module's own state. A reload starts it over; the daemon keeps what matters.
72let wsAddress = ''
73let daemon: Promise<Daemon | null> | null = null
74// Starts that failed in a row, the latest start, and what the last failure
75// said: the same failure again is not toasted again.
76let startFailures = 0
77let startAttempt = 0
78let lastStartError = ''
79// True while the session ends: no daemon is started only to be told so.
80let isEnding = false
81let sessionID: string | null = null
82const queue: DaemonEvent[] = []
83let sending: Promise<void> | null = null
84// Tool calls whose permission check said "ask", by call ID, until a prompt
85// for that tool and input comes up; then the calls whose prompt is still
86// unanswered.
87const mayAsk = new Map<string, { tool: string; input: string }>()
88const asked = new Set<string>()
89// A subagent's calls that have not finished, each with its subagent. One in
90// the background outlives the turn that started it, so a turn's start and end
91// leave these as they are: they go when the call finishes or its subagent's
92// run ends.
93const agentCalls = new Map<string, string>()
94/** Whose call it is, for the daemon: a subagent's is its subagent's there, its prompt included. */
95const whoseCall = (id: string) => {
96 const agent = agentCalls.get(id)
97
98 return agent === undefined ? {} : { agent }
99}
100const forgetTurnCalls = () => {
101 for (const id of [...mayAsk.keys(), ...asked]) {
102 if (agentCalls.has(id)) continue
103 mayAsk.delete(id)
104 asked.delete(id)
105 }
106}
107const dialog = atom({ plugin: 'vibeclaude', key: 'dialog' } as const, { title: '', devices: [], selected: null })
108
109const toast = ($: EngineInterface, message: string) => {
110 $.ui.toast(`Intiface: ${message}`, { timeoutMs: TOAST_MS })
111}
112
113// The daemon ---------------------------------------------------------------
114
115const startDaemon = async ($: EngineInterface): Promise<Daemon | null> => {
116 const attempt = ++startAttempt
117 try {
118 const { exitCode, stdout, stderr } = await $.process.run(
119 ['node', `${$.plugin.root}/daemon/daemon.mjs`, 'start', ...(wsAddress ? ['--ws', wsAddress] : [])],
120 { timeoutMs: DAEMON_START_TIMEOUT_MS },
121 )
122 if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
123 const started = JSON.parse(stdout) as Daemon
124 if (typeof started.port !== 'number') throw new Error('unexpected answer')
125 startFailures = 0
126 lastStartError = ''
127 return started
128 } catch (e) {
129 const error = String(e)
130 if (error !== lastStartError) toast($, `Daemon failed to start: ${error}`)
131 lastStartError = error
132 $.clock.after(Math.min(DAEMON_RETRY_MS * 2 ** startFailures++, DAEMON_RETRY_MAX_MS), () => {
133 if (attempt === startAttempt) daemon = null
134 })
135 return null
136 }
137}
138
139/** The daemon, started if need be. A command the person ran (`isAsked`) does not wait out a failed start. */
140const getDaemon = async ($: EngineInterface, isAsked = false): Promise<Daemon | null> => {
141 const current = daemon
142 if (isAsked && current !== null && startFailures > 0 && (await current) === null && daemon === current) {
143 daemon = null
144 lastStartError = ''
145 }
146 if (daemon === null && isEnding) return null
147 return (daemon ??= startDaemon($))
148}
149
150const post = async <T,>($: EngineInterface, { port }: Daemon, path: string, body: unknown): Promise<T> => {
151 const response = await $.http.fetch(`http://127.0.0.1:${port}${path}`, {
152 method: 'POST',
153 headers: { 'content-type': 'application/json' },
154 body: JSON.stringify(body),
155 })
156 if (!response.ok) throw new Error(`the daemon answered ${response.status}`)
157 return JSON.parse(response.text) as T
158}
159
160// Events -------------------------------------------------------------------
161// Queued and sent one request at a time, so they reach the daemon in the
162// order they happened and a slow request never holds up the model's stream.
163
164const drain = async ($: EngineInterface) => {
165 while (queue.length > 0) {
166 const target = await getDaemon($)
167 const events = queue.splice(0)
168 if (target === null) continue
169 const session = events.some((event) => event.session === undefined) ? (sessionID ??= await $.session.id()) : ''
170 const body = (to: Daemon) => ({
171 pid: to.pid,
172 events: events.map((event) => ({ ...event, session: event.session ?? session })),
173 })
174 try {
175 await post($, target, '/events', body(target))
176 } catch {
177 // The daemon went away (it was stopped, or it crashed): start another.
178 daemon = null
179 const restarted = await getDaemon($)
180 if (restarted !== null) await post($, restarted, '/events', body(restarted)).catch(() => {})
181 }
182 }
183}
184
185/** Resolves once everything queued so far has been sent, or given up on: what is queued as a drain ends is drained too. */
186const flush = async ($: EngineInterface): Promise<void> => {
187 while (queue.length > 0 || sending !== null) {
188 await (sending ??= drain($)
189 .catch(() => {})
190 .finally(() => {
191 sending = null
192 }))
193 }
194}
195
196const emit = ($: EngineInterface, event: DaemonEvent) => {
197 queue.push(sessionID === null ? event : { session: sessionID, ...event })
198 void flush($)
199}
200
201// Toasts -------------------------------------------------------------------
202// The daemon's notices ("Restored …", "Connection to Intiface was lost"),
203// shown as they happen: each request waits at the daemon until there is one.
204
205// The module outlives a /clear, and each session starts its own poller: only
206// the latest one goes on, or every notice would be shown once per session.
207let poller = 0
208
209const pollNotices = async ($: EngineInterface, cursor: string, generation: number): Promise<void> => {
210 if (generation !== poller) return
211 const retry = () => {
212 $.clock.after(NOTICE_RETRY_MS, () => void pollNotices($, cursor, generation))
213 }
214 const target = await getDaemon($)
215 if (target === null) return retry()
216 let next: string
217 try {
218 const response = await $.http.fetch(`http://127.0.0.1:${target.port}/notices?${cursor}`)
219 const { id, seq, notices } = JSON.parse(response.text) as {
220 id: string
221 seq: number
222 notices: { message: string }[]
223 }
224 if (generation !== poller) return
225 for (const notice of notices) toast($, notice.message)
226 next = `id=${id}&after=${seq}`
227 } catch {
228 return retry()
229 }
230 void pollNotices($, next, generation)
231}
232
233// Commands -----------------------------------------------------------------
234
235const command = async (
236 $: EngineInterface,
237 body: { cmd: string; index?: number; value?: number },
238): Promise<CommandReply> => {
239 const target = await getDaemon($, true)
240 if (target === null) return { error: 'The daemon is not running' }
241 try {
242 return await post<CommandReply>($, target, '/cmd', body)
243 } catch {
244 // The daemon went away (it was stopped, or it crashed): start another.
245 daemon = null
246 const restarted = await getDaemon($, true)
247 if (restarted === null) return { error: 'The daemon is not running' }
248 try {
249 return await post<CommandReply>($, restarted, '/cmd', body)
250 } catch (e) {
251 daemon = null
252 return { error: `Command failed: ${String(e)}` }
253 }
254 }
255}
256
257const report = ($: EngineInterface, reply: CommandReply) => {
258 const message = reply.error ?? reply.message
259 if (message !== undefined) toast($, message)
260}
261
262// /intiface: a dialog that says which step it is on (Connecting, Scanning),
263// then lists what can vibrate. Picking a device selects it and closes the
264// dialog; Escape closes it at any step.
265
266const showDialog = ($: EngineInterface, title: string, devices: Device[] = [], selected: number | null = null) =>
267 update($, dialog, () => ({ title, devices, selected }))
268
269const scanIntoDialog = async ($: EngineInterface, isConnected: boolean) => {
270 const fail = async (reply: CommandReply) => {
271 report($, reply)
272 await $.ui.close({ id: DIALOG })
273 }
274
275 if (!isConnected) {
276 const connection = await command($, { cmd: 'ensure-connected' })
277 if (connection.error !== undefined) return fail(connection)
278 await showDialog($, 'Scanning')
279 }
280 const scan = await command($, { cmd: 'scan' })
281 if (scan.error !== undefined) return fail(scan)
282
283 const devices = scan.devices ?? []
284 if (devices.length === 0) return showDialog($, 'No vibration-capable devices found')
285 await showDialog($, 'Select device', devices, scan.selected ?? null)
286}
287
288const openDeviceDialog = async ($: EngineInterface) => {
289 const state = await command($, { cmd: 'state' })
290 if (state.error !== undefined) return report($, state)
291
292 const isConnected = state.connected === true
293 await showDialog($, isConnected ? 'Scanning' : 'Connecting')
294 await $.ui.open({ id: DIALOG, title: 'Intiface', focus: true, closeOnEscape: true, rows: DIALOG_ROWS })
295 // The rest goes on behind the open dialog, which stays the person's to close.
296 void scanIntoDialog($, isConnected).catch((e) => toast($, `Command failed: ${String(e)}`))
297}
298
299export const register: Register = (on, options) => {
300 wsAddress = typeof options.wsAddress === 'string' ? options.wsAddress : ''
301
302 // Session ------------------------------------------------------------------
303
304 on('session.start', async ($, e, next) => {
305 const startedAt = await $.clock.now()
306 sessionID = await $.session.id()
307 emit($, { event: 'session.start' })
308 void pollNotices($, `since=${startedAt}`, ++poller)
309
310 await Promise.all([
311 $.command.register({
312 name: 'intiface',
313 description: 'Connect to Intiface, scan, and select a device',
314 immediate: true,
315 }),
316 $.command.register({
317 name: 'intiface-connect',
318 description: 'Connect to the configured Intiface server',
319 immediate: true,
320 }),
321 $.command.register({
322 name: 'intiface-disconnect',
323 description: 'Disconnect from Intiface and stop all device output',
324 immediate: true,
325 }),
326 $.command.register({
327 name: 'intiface-intensity',
328 description: 'Set how strong all device output is, from 0 to 1',
329 argumentHint: '[0.0-1.0]',
330 immediate: true,
331 }),
332 ])
333
334 return next(e)
335 })
336
337 on('session.end', async ($, e, next) => {
338 isEnding = true
339 emit($, { event: 'session.end', session: e.sessionId, reason: e.reason })
340 await flush($).finally(() => {
341 isEnding = false
342 })
343 // After a /clear the same Claude Code goes on under another session id.
344 sessionID = null
345
346 return next(e)
347 })
348
349 on('command.run', { command: 'intiface' }, async $ => {
350 await openDeviceDialog($)
351
352 return {}
353 })
354
355 on('ui.render', { component: 'Pane', requestId: DIALOG }, async ($, e) => {
356 const { Box, Text, Button } = $.ui.resolve(e)
357 const { title, devices, selected } = await read($, dialog)
358 const focused = devices.find((device) => device.index === selected) ?? devices[0]
359
360 return (
361 <Box flexDirection="column">
362 <Text bold>{title}</Text>
363 {devices.map((device) => (
364 <Box key={`row-${device.index}`} gap={1}>
365 <Button
366 key={`device-${device.index}`}
367 label={device.name}
368 plain
369 {...(device === focused ? { autoFocus: true } : {})}
370 onPress={async () => {
371 report($, await command($, { cmd: 'select', index: device.index }))
372 await $.ui.close({ id: DIALOG })
373 }}
374 />
375 <Text dimColor>{`Device #${device.index}`}</Text>
376 {selected === device.index && <Text color="success">Connected</Text>}
377 </Box>
378 ))}
379 </Box>
380 )
381 })
382
383 on('command.run', { command: 'intiface-connect' }, async $ => {
384 report($, await command($, { cmd: 'connect' }))
385
386 return {}
387 })
388
389 on('command.run', { command: 'intiface-disconnect' }, async $ => {
390 report($, await command($, { cmd: 'disconnect' }))
391
392 return {}
393 })
394
395 // With no argument it says what the intensity is. One that is not a number
396 // reaches the daemon as null, and is refused there with the rest.
397 on('command.run', { command: 'intiface-intensity' }, async ($, e) => {
398 const text = e.args.trim()
399 report($, await command($, text === '' ? { cmd: 'intensity' } : { cmd: 'intensity', value: Number(text) }))
400
401 return {}
402 })
403
404 // Turns --------------------------------------------------------------------
405
406 on('turn.start', async ($, e, next) => {
407 sessionID ??= await $.session.id()
408 forgetTurnCalls()
409 emit($, { event: 'turn.start' })
410
411 return next(e)
412 })
413
414 on('turn.complete', ($, e, next) => {
415 // A subagent's run ending is not the turn ending, but it is the end of
416 // that subagent's calls: one that never said it finished is over too. The
417 // daemon is told either way, and how the run ended: it is felt there, and
418 // the daemon may know of calls this module has forgotten (a reload starts
419 // the module over).
420 if (e.agentId === undefined) {
421 forgetTurnCalls()
422 emit($, { event: 'turn.complete', reason: e.reason })
423 } else {
424 for (const [id, agent] of agentCalls) {
425 if (agent !== e.agentId) continue
426 agentCalls.delete(id)
427 mayAsk.delete(id)
428 asked.delete(id)
429 }
430 emit($, { event: 'agent.complete', agent: e.agentId, reason: e.reason })
431 }
432
433 return next(e)
434 })
435
436 // The model's response, piece by piece. Thinking and text are felt as they
437 // stream; a tool call is felt from the moment the model starts writing it.
438 // A subagent's response is felt as the conversation's own is.
439 on('turn.step', async function* ($, e, next) {
440 // Whose response it is, for the daemon: a subagent's is played apart from
441 // the conversation's, goes on past the end of the turn that started it,
442 // and ends with its own run.
443 const whose = e.agentId === undefined ? {} : { agent: e.agentId }
444 // A subagent's run counts its steps as a turn does: its blocks are named
445 // for it too, so that no two loops' share an ID.
446 const step = `${e.agentId === undefined ? '' : `${e.agentId}:`}${e.turnId}:${e.index}`
447 let open = null as { kind: 'reasoning' | 'text' | 'tool.input'; id: string; chars: number } | null
448 let isLive = false
449
450 // Blocks arrive one after another, and nothing marks a block's end but
451 // the next one beginning or the response stopping.
452 const close = () => {
453 if (open === null) return
454 emit($, { event: `${open.kind}.ended`, id: open.id, ...whose })
455 open = null
456 }
457 const enter = (kind: 'reasoning' | 'text' | 'tool.input', id: string) => {
458 if (open !== null && open.kind === kind && open.id === id) return open
459 close()
460 open = { kind, id, chars: 0 }
461 return open
462 }
463
464 const stream = next(e)
465 try {
466 for await (const chunk of stream) {
467 if (chunk.kind === 'thinking' || chunk.kind === 'text') {
468 const kind = chunk.kind === 'thinking' ? 'reasoning' : 'text'
469 const id = `${step}:${chunk.index}`
470 // The engine keeps thinking's text to itself where no one is shown
471 // it: the chunks still come, empty, and nothing is felt.
472 if (enter(kind, id).chars++ === 0) emit($, { event: `${kind}.started`, id, ...whose })
473 if (chunk.text.length > 0) emit($, { event: `${kind}.delta`, id, chars: chunk.text.length, ...whose })
474 // A block that has started makes the response live, text or not.
475 isLive = true
476 } else if (chunk.kind === 'tool') {
477 if (enter('tool.input', chunk.id).chars++ === 0) {
478 emit($, { event: 'tool.input.started', id: chunk.id, tool: chunk.name, ...whose })
479 }
480 } else if (chunk.kind === 'stop') {
481 // A block's end is one of the engine's own items, which cannot be
482 // told from its others (a retry marker, the envelope): a call's
483 // arguments are taken as whole when the next block begins or the
484 // response stops. The daemon ends the wait itself if the call runs first.
485 close()
486 }
487 yield chunk
488 }
489 } finally {
490 close()
491 }
492
493 const response = await stream.result
494 // A part that arrived whole is felt once, by its ID, unless the response
495 // was live: one that streamed anything, thinking included, has been felt.
496 // The response's text is the one part that can arrive whole: thinking
497 // reaches a plugin only as it streams.
498 if (!isLive && response.answer.length > 0) {
499 emit($, { event: 'output.snapshot', id: `${step}:answer`, ...whose })
500 }
501
502 return response
503 })
504
505 // Subagents ----------------------------------------------------------------
506
507 // Which call started a subagent, once it has started. For as long as that
508 // call runs the subagent is in the foreground, and what cuts the call short
509 // (an interrupt) cuts the subagent short with it: the daemon, told which
510 // call it was, does not have to guess at that.
511 //
512 // It only watches: one that fails lets the subagent start as it would have.
513 on('agent.spawn', async ($, e, next) => {
514 const started = await next(e)
515 if (started.agentId !== undefined) {
516 emit($, { event: 'agent.started', agent: started.agentId, id: e.tool_use_id })
517 }
518
519 return started
520 }).catch(($, e, next) => next(e))
521
522 // Tools --------------------------------------------------------------------
523
524 on('tool.call', async ($, e, next) => {
525 const id = e.tool_use_id
526 const tool = String(e.tool)
527 // Whose call it is, at its end as at its start: one that ends after its
528 // subagent's run has is told from the conversation's own.
529 const whose = e.agentId === undefined ? {} : { agent: e.agentId }
530 if (e.agentId !== undefined) agentCalls.set(id, e.agentId)
531 emit($, { event: 'tool.started', id, tool, ...whose })
532
533 const finish = (status: 'completed' | 'error') => {
534 agentCalls.delete(id)
535 mayAsk.delete(id)
536 asked.delete(id)
537 const update = status === 'completed' ? todoUpdate(tool, e as Record<string, unknown>) : {}
538 emit($, { event: 'tool.finished', id, tool, status, ...update, ...whose })
539 }
540
541 try {
542 const ran = await next(e)
543 const hasFailed = ran.deny !== undefined || ran.isError === true
544 finish(hasFailed ? 'error' : 'completed')
545
546 return ran
547 } catch (error) {
548 finish('error')
549 throw error
550 }
551 }).catch(($, e, next) => next(e))
552
553 // Permission prompts. The engine draws the dialog itself and says neither
554 // which call it is for nor when it was answered, so both are worked out:
555 // the call is the one whose check said "ask" for the tool and input the
556 // prompt names (failing that, the tool alone), and the answer has come once
557 // the call is running, fails or finishes.
558 //
559 // All three only watch: one that fails lets the call go on as it would have.
560
561 on('tool.check', async ($, e, next) => {
562 const verdict = await next(e)
563 if (verdict.decision === 'ask' && e.tool_use_id !== undefined) {
564 mayAsk.set(e.tool_use_id, { tool: e.tool, input: JSON.stringify(e.input) })
565 }
566
567 return verdict
568 }).catch(($, e, next) => next(e))
569
570 on('classic.PermissionRequest', ($, e, next) => {
571 const input = JSON.stringify(e.tool_input)
572 const calls = [...mayAsk].filter(([, call]) => call.tool === e.tool_name)
573 const [id] = calls.find(([, call]) => call.input === input) ?? calls[0] ?? []
574 if (id !== undefined) {
575 mayAsk.delete(id)
576 asked.add(id)
577 emit($, { event: 'permission.asked', id, ...whoseCall(id) })
578 }
579
580 return next(e)
581 }).catch(($, e, next) => next(e))
582
583 // The run-in-background hint is drawn under a call that is running: for a
584 // long command, the first sign that its prompt was answered. Only the
585 // terminal draws it, and for calls that share one hint it names the first:
586 // any other call's answer is known when the call finishes.
587 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
588 const id = e.props.tool_use_id
589 // A running call is past its permission check, prompt or none.
590 mayAsk.delete(id)
591 if (asked.delete(id)) emit($, { event: 'permission.replied', id, ...whoseCall(id) })
592
593 return next(e)
594 })
595}
596types/index.d.ts 11 lines1export type Device = { index: number; name: string }
2
3/** What the /intiface dialog shows: the step it is on, then the devices to pick from. */
4export type DeviceDialog = { title: string; devices: Device[]; selected: number | null }
5
6declare module 'claude-code' {
7 interface PluginState {
8 vibeclaude: { dialog: DeviceDialog }
9 }
10}
11