SLOPSHOPPER

vibeclaude

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

paneguardcommandtoastprocessnetwork
★ 161v0.1.0MITupdated 2026-10-07transkatgirl/vibeclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vibeclaude
│ ┃ intiface ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ │ vibeclaude │ │ ⏺ Read(src/auth.ts) │ Intiface: Daemon failed to start: │ │ ⎿ Read 6 lines │ SyntaxError: JSON Parse error: Unexpected │ │ ⏺ Update(src/auth.ts) │ identifier "dev" │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) ╭─────────────────────────────────────╮ │ ⎿ 3 pass, 1 fail │ vibeclaude │ │ │ Intiface: The daemon is not running │ │ ● Done. refresh now rejects e╰─────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /intiface │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · intiface
README

VibeClaude

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

Requirements

  • Claude Code with function-hooks plugin support (an early-access API; built against 2.1.291)
  • Node.js 22+, as node on your PATH (the buttplug client does not run on older versions)
  • Intiface Central with its WebSocket server running (default ws://127.0.0.1:12345)
  • A little desire
  • A comfortable, private setting
  • Some lubricant (optional, but strongly recommended)
  • A vibration-capable "controller", paired in Intiface Central

Install

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.

Use

  1. Start Intiface Central and press the server start button.
  2. In Claude Code run /intiface.
  3. Select a vibration-capable device from the scan results.
  4. Get comfortable, connect with your device, and slowly adjust to the feeling of becoming one with it.
  5. Start enjoying your coding session.
CommandDescription
/intifaceConnect to Intiface, scan, and select a device.
/intiface-connectConnect to the configured Intiface server and go back to the device you last selected, if it is there.
/intiface-disconnectDisconnect 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.

What you feel

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 happensPattern
Thinking and text, as they streamA 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 callOne 120ms pulse at 0.5
The model starts writing an Edit/Write/MultiEdit/NotebookEditNo 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 succeedsOne slow, deep 180ms pulse at 0.7
A tool call fails, is denied, or is rejected at its promptOne sudden, sharp 280ms pulse at 1.0
A todo/task tool completes an itemOne pulse at 0.8, replaced at once by the tool's own success pulse
A subagent's run endsThe pulse of a tool call succeeding; of one failing if the run was interrupted or failed
A permission prompt is waitingThree 90ms pulses at 0.85, a pause, repeating until answered. Thinking and text are not felt meanwhile
The turn endsThree 100ms pulses at 1.0
The turn is interrupted or failsEverything of the turn's stops, then the three pulses of a turn ending
The session endsEverything 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:

  • Thinking. Claude Code hands a plugin thinking's text only where it is shown to someone. Where it is withheld (a headless claude -p run is one such place), thinking is not felt.
  • Answering a permission prompt. Claude Code draws the prompt itself and does not say when it was answered. The pattern stops when the tool that asked finishes or fails, or when you reject it. So after you approve a call that takes a while, the pattern goes on until the call is done. The one earlier sign is the run-in-background hint Claude Code draws under a long-running command a moment after it starts: that stops the pattern too, but only in the terminal, and where several commands share one hint, only for the first of them. A subagent's prompt also stops when the subagent's run ends. Other tool calls running alongside do not silence it.
  • Tool output. Nothing reports a command's output as it is printed, so it is not felt: a tool's result is its one pulse.

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.

Configure

Claude Code asks for the plugin's options when you enable it; change them later under /plugin → manage.

OptionDefaultDescription
wsAddressws://127.0.0.1:12345Intiface 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.

How it works

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:

CommandWhat it does
node daemon/daemon.mjs scanConnect, scan 3s, list vibration-capable devices
node daemon/daemon.mjs select <index>Select a device from the scan
node daemon/daemon.mjs connect / disconnectConnect 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 startMake sure a daemon is running; print its port
node daemon/daemon.mjs statusIs a daemon answering?
node daemon/daemon.mjs stopStop all output and exit the daemon
node daemon/daemon.mjsRun 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.

Try it without hardware

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

Privacy

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'.

Not included (yet)

  • Live tool output, and the exact moment a permission prompt is answered: see above.
  • Patterns that scale with the size of an edit. The original's code for this is never reached, so there is nothing to port.
  • Codex CLI: its 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.
  • Non-vibration outputs (rotate, oscillate, linear). Pull requests welcome.

License

This project is licensed under the MIT License. Buttplug and Intiface are trademarks of Nonpolynomial Labs, LLC.

Source 2 files
hooks/register.tsx 596 lines
1// 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}
596
types/index.d.ts 11 lines
1export 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