SLOPSHOPPER

localvoxtral-mod

Shows whether localvoxtral is connected to this Claude Code session, puts dictated text in the prompt box, shows the dictation above the prompt, and ends a…

newpanebandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · localvoxtral-mod
│ ┃ Inbox ✕ › fix the failing auth test and add an audit log call │ ┃ The localvoxtral command is not installed │ ┃ here. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /inbox │ ⎿ localvoxtral-mod: Opened the Inbox pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Inbox
The localvoxtral command is not installed here.
README

<h1 align="center">localvoxtral</h1>

<img src="assets/icons/app/AppIcon.png" alt="localvoxtral app icon" width="128" height="128" />

<strong>Talk to your coding agents. Keep every word on your Mac.</strong><br /> Realtime, fully local dictation for the menu bar. Press a key and speak. Your words appear while you're still talking.

<a href="#install">Install</a> · <a href="https://t0msilver.github.io/localvoxtral/docs/">Documentation</a> · <a href="https://t0msilver.github.io/localvoxtral/docs/coding-agents/">Coding agents</a> · <a href="CONTRIBUTING.md">Contributing</a>

<a href="https://github.com/T0mSIlver/localvoxtral/stargazers"><img src="https://img.shields.io/github/stars/T0mSIlver/localvoxtral?style=social" alt="GitHub stars" /></a> &nbsp; <a href="https://github.com/T0mSIlver/localvoxtral/releases/latest"><img src="https://img.shields.io/github/v/release/T0mSIlver/localvoxtral?label=release" alt="Latest release" /></a> &nbsp; <a href="LICENSE"><img src="https://img.shields.io/github/license/T0mSIlver/localvoxtral" alt="License" /></a>

https://github.com/user-attachments/assets/81a341ff-0c53-4fcf-9b7f-ef148b24dfae

localvoxtral streams text as the audio arrives instead of transcribing after you stop speaking. It runs Mistral AI's Voxtral Mini 4B Realtime on your own Apple Silicon.

It is built first for prompting coding agents by voice, and it works as a general dictation app in any other app too. Everything runs on-device, with no account and no subscription. Nothing leaves your Mac unless you point it at a server yourself.

Install

curl -fsSL https://raw.githubusercontent.com/T0mSIlver/localvoxtral/main/scripts/install.sh | bash

Or install with Homebrew:

brew install --cask T0mSIlver/localvoxtral/localvoxtral

You can also download the latest DMG from Releases. localvoxtral needs an Apple Silicon Mac on macOS 15 or later.

On first launch, a setup wizard asks for permissions and downloads the engine.

Features

  • Jump to the agent that needs you. When one of your coding agents waits for an answer, localvoxtral tells you. Press Tab while you dictate, and that agent's pane comes to the front so you can read its question while you answer. Your words go there (details).
  • Built for coding agents. Dictate prompts straight into any CLI agent (opencode, Mistral Vibe and Codex get their own integrations), in any terminal: Warp, WezTerm, kitty, Alacritty, and more. Polishing understands developer speech: "dash dash force" becomes --force, "use auth dot t s" becomes useAuth.ts (details).
  • Claude Code aware. Dictation joins the exact session under your cursor: Ghostty, iTerm2, Terminal.app, a single herdr or cmux pane, over SSH, or a claude.ai/code Remote Control tab in your browser. Polishing is grounded in that session's screen, your last prompt, the files Claude just touched, and the repo's vocabulary (details).
  • One key. Tap or hold to dictate into an overlay you can review, with optional LLM polishing. Press Tab to save the words to your Inbox instead (shortcuts).
  • Private. Audio capture, transcription and polishing run on your Mac. No telemetry, no account, no cloud fallback (how it works).
  • Menu bar native. The popover shows dictation status and a microphone picker. The app can copy the final text for you, and after a polished commit the raw transcript is one click away.
  • Bring your own server. Dictation and polishing can each point at any OpenAI-compatible endpoint, or at Mistral's hosted API with one key, instead of the built-in local engines (details).
  • Multilingual. Dictate in English, French, or any language Voxtral understands. Polishing answers in the language you spoke (English and French are covered by the test suite).

[!TIP] If localvoxtral is useful to you, a ⭐ on this repo helps others find it.

Documentation

Every guide is listed in the documentation index.

License

MIT

Source 6 files
hooks/register.tsx 851 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HttpResponse, Register } from 'claude-code'
3
4import type { Band, InboxView } from '../types'
5import {
6  AppendStream,
7  BAND_STALE_MS,
8  bandOf,
9  type ChannelBye,
10  type ChannelMessage,
11  draftOf,
12  insertedAt,
13  needsKeys,
14  type ChannelReply,
15  NEW_SESSION_POLL_MS,
16  NEW_SESSION_WAIT_MS,
17  NO_TURN,
18  NOT_RESTORED,
19  type Outcome,
20  parseMessage,
21  PUT_BACK_RETRY_MS,
22  PUT_BACK_TRIES,
23  waitingLine,
24  RESTART_DELAY_MS,
25  SESSION_CHANGED,
26  SHORTEST_LIFE_MS,
27  SUBMIT_ANSWER_MS,
28  WIRE_VERSION,
29} from './channel'
30import { sameHex } from './hmac'
31import { detailOf, INBOX_PANE, inboxOf } from './inbox'
32import {
33  answerProof,
34  BACKOFF_MS,
35  BUSY_RETRY_MS,
36  hookOKAt,
37  isChannelKey,
38  isToken,
39  ASK_ABANDON_MS,
40  INBOX_OPEN_PATH,
41  INBOX_PATH,
42  type InboxRequest,
43  parsePollAnswer,
44  POLL_ABANDON_MS,
45  POLL_PATH,
46  type PollRequest,
47  PROOF_HEADER,
48  randomHex,
49  remotePort,
50  REPLY_PATH,
51  requestProof,
52  STAMP_CHECK_MS,
53} from './remote'
54
55// The connection indicator the publisher draws for the settings status line
56// (../../../README.md, "Connection indicator"), pinned as this plugin's own
57// status line: no edit to ~/.claude/settings.json, and the person's own
58// status line stays theirs.
59//
60// Fail-open like the command hooks: no publisher, a failed run or an empty
61// answer clears the line and never throws into the session.
62
63const REFRESH_MS = 5000
64const ANSI = /\u001b\[[0-9;]*m/g
65// The two commands the app's Status line row writes: the publisher itself,
66// or the script that combines it with the person's own line.
67const SETTINGS_INDICATOR = /localvoxtral-claude-hook|localvoxtral-statusline\.sh/
68
69async function findPublisher($: EngineInterface, configured: string): Promise<string | undefined> {
70  const home = (await $.env.get('HOME')) ?? ''
71  // The shim's order (../../localvoxtral/hooks/publish.sh), so both find
72  // the same binary.
73  const candidates = [
74    (await $.env.get('LOCALVOXTRAL_CLAUDE_HOOK_BIN')) ?? '',
75    `${home}/Library/Application Support/localvoxtral/claude/publisher`,
76    configured,
77    '/Applications/localvoxtral.app/Contents/MacOS/localvoxtral-claude-hook',
78    `${home}/Applications/localvoxtral.app/Contents/MacOS/localvoxtral-claude-hook`,
79  ]
80  for (const path of candidates) {
81    if (path !== '' && (await $.fs.exists(path))) return path
82  }
83  return undefined
84}
85
86/** The `localvoxtral` command: the app's own copy beside the publisher first. */
87async function findCLI($: EngineInterface, configuredPublisher: string): Promise<string | undefined> {
88  const home = (await $.env.get('HOME')) ?? ''
89  const bundled = 'localvoxtral.app/Contents/MacOS/localvoxtral-cli'
90  const candidates = [
91    (await $.env.get('LOCALVOXTRAL_CLI_BIN')) ?? '',
92    configuredPublisher.endsWith('/localvoxtral-claude-hook')
93      ? configuredPublisher.replace(/localvoxtral-claude-hook$/, 'localvoxtral-cli')
94      : '',
95    `/Applications/${bundled}`,
96    `${home}/Applications/${bundled}`,
97    '/usr/local/bin/localvoxtral',
98  ]
99  for (const path of candidates) {
100    if (path !== '' && (await $.fs.exists(path))) return path
101  }
102  return undefined
103}
104
105async function settingsShowIndicator($: EngineInterface): Promise<boolean> {
106  const { statusLine } = await $.settings.read()
107  const command =
108    typeof statusLine === 'object' && statusLine !== null
109      ? (statusLine as { command?: unknown }).command
110      : undefined
111  return typeof command === 'string' && SETTINGS_INDICATOR.test(command)
112}
113
114/**
115 * How replies and byes reach the app: the publisher's `--mod-reply` on the
116 * Mac, or the listener through a remote host's forward (#1412).
117 */
118type Link =
119  | { kind: 'publisher'; publisher: string }
120  | { kind: 'remote'; port: number; token: string; key: string }
121type RemoteLink = Extract<Link, { kind: 'remote' }>
122
123/**
124 * The remote link the options describe: only the remote plugin's copy of
125 * this module has a `token` field, and it attaches only with a token and a
126 * channel key.
127 */
128function remoteLinkOf(options: Record<string, unknown>): RemoteLink | undefined {
129  const { token, channel_key: key, port } = options
130  if (!isToken(token) || !isChannelKey(key)) return undefined
131  return { kind: 'remote', port: remotePort(port), token, key }
132}
133
134function remoteHeaders(link: RemoteLink, body: string): Record<string, string> {
135  return {
136    Authorization: `Bearer ${link.token}`,
137    'Content-Type': 'application/json',
138    [PROOF_HEADER]: requestProof(link.key, body),
139  }
140}
141
142/** Hands one reply or bye line to the app, within `timeoutMs`. */
143async function sendToApp($: EngineInterface, link: Link, line: string, timeoutMs: number): Promise<void> {
144  if (link.kind === 'publisher') {
145    await $.process.run([link.publisher, '--mod-reply'], { stdin: `${line}\n`, timeoutMs })
146    return
147  }
148  const sent = $.http.fetch(`http://127.0.0.1:${link.port}${REPLY_PATH}`, {
149    method: 'POST',
150    headers: remoteHeaders(link, line),
151    body: line,
152  })
153  sent.catch(() => {})
154  await Promise.race([sent, $.clock.sleep(timeoutMs)])
155}
156
157// The session the mod said `bye` for, and whether the process ends with it
158// (any end but `/clear`). Module state: `session.end` sets it, the channel
159// loop reads it.
160let endingSession: string | undefined
161let processEnds = false
162// Resolves when `session.end` cuts the channel of a `/clear`, so the read
163// loop stops waiting on a child the app may never close.
164let cutChannel: () => void = () => {}
165
166/**
167 * Keeps `--attach` running for the session's life and answers each message
168 * with what `handle` did. After the app's `bye`, attaches again under the
169 * session id a `/clear` moved the process to (#1646).
170 * Never throws into the session.
171 */
172async function runPublisherChannel(
173  $: EngineInterface,
174  link: Extract<Link, { kind: 'publisher' }>,
175): Promise<void> {
176  const { publisher } = link
177  let sessionID = await $.session.id()
178  for (;;) {
179    const startedAt = await $.clock.now()
180    let saidBye = false
181    try {
182      let buffered = ''
183      const child = $.process.spawn({ argv: [publisher, '--attach', '--session', sessionID] })
184      const cut = new Promise<'cut'>((resolve) => {
185        cutChannel = () => resolve('cut')
186      })
187      read: for (;;) {
188        const piece = await Promise.race([child.next(), cut])
189        if (piece === 'cut') {
190          // Ends the child; not awaited, since a pull may still be pending.
191          void child.return(undefined as never).catch(() => {})
192          break
193        }
194        if (piece.done === true) break
195        const { stream, text } = piece.value
196        if (stream !== 'stdout') continue
197        buffered += text
198        let newline = buffered.indexOf('\n')
199        while (newline >= 0) {
200          const message = parseMessage(buffered.slice(0, newline))
201          buffered = buffered.slice(newline + 1)
202          if (message !== null && dispatch($, link, sessionID, message) === 'bye') {
203            // The app ended this session's channel; leaving the loop ends
204            // the child.
205            saidBye = true
206            void child.return(undefined as never).catch(() => {})
207            break read
208          }
209          newline = buffered.indexOf('\n')
210        }
211      }
212    } catch {
213      // The child could not start; the restart below decides what is next.
214    }
215    // Nobody tells this mod who waits until it attaches again.
216    await update($, waiting, () => [])
217    if (saidBye || endingSession === sessionID) {
218      if (processEnds) return
219      const next = await newSessionID($, sessionID)
220      if (next === undefined) return
221      sessionID = next
222      continue
223    }
224    if ((await $.clock.now()) - startedAt < SHORTEST_LIFE_MS) return
225    await $.clock.sleep(RESTART_DELAY_MS)
226  }
227}
228
229/** Acts on one message from the app; says when it is the app's `bye`. */
230function dispatch($: EngineInterface, link: Link, sessionID: string, message: ChannelMessage): 'bye' | undefined {
231  if (message.kind === 'bye') return 'bye'
232  if (message.kind === 'state' && message.waiting !== undefined) void update($, waiting, () => message.waiting ?? [])
233  else if (message.kind === 'state') void showBand($, message)
234  else if (message.kind === 'append') queueAppend($, sessionID, message)
235  // Marks every append that arrived before it, queued behind a slow fill
236  // or not, as the cancelled dictation's (#1805). Not answered.
237  else if (message.kind === 'cancel') cancels += 1
238  else void answer($, link, sessionID, message)
239  return undefined
240}
241
242/**
243 * The channel from a remote host (#1412): long polls on the app's listener
244 * through the forward, each answer's lines handled as the publisher's are.
245 * A dial that fails, or an answer without the channel key's proof, waits
246 * out `BACKOFF_MS` unless a hook reaches the app sooner. Never throws into
247 * the session.
248 */
249async function runRemoteChannel($: EngineInterface, link: RemoteLink): Promise<void> {
250  let sessionID = await $.session.id()
251  const instance = randomHex(16)
252  let attach: number | undefined
253  let acked = 0
254  let challenge = ''
255  let failedAt: number | undefined
256  for (;;) {
257    if (failedAt !== undefined) {
258      await backOff($, failedAt)
259      failedAt = undefined
260    }
261    let saidBye = endingSession === sessionID
262    if (!saidBye) {
263      const nonce = randomHex(16)
264      const request: PollRequest = {
265        mod_poll: WIRE_VERSION,
266        session_id: sessionID,
267        instance,
268        nonce,
269        challenge,
270        attach: attach ?? 0,
271        acked,
272      }
273      const body = JSON.stringify(request)
274      // Good once: a failure below starts over without one.
275      challenge = ''
276      const cut = new Promise<'cut'>((resolve) => {
277        cutChannel = () => resolve('cut')
278      })
279      let answered: HttpResponse | 'late' | 'cut'
280      try {
281        const polled = $.http.fetch(`http://127.0.0.1:${link.port}${POLL_PATH}`, {
282          method: 'POST',
283          headers: remoteHeaders(link, body),
284          body,
285        })
286        polled.catch(() => {})
287        answered = await Promise.race([polled, $.clock.sleep(POLL_ABANDON_MS).then(() => 'late' as const), cut])
288      } catch {
289        answered = 'late'
290      }
291      if (answered === 'late') {
292        // Nobody tells this mod who waits until it attaches again.
293        await update($, waiting, () => [])
294        failedAt = await $.clock.now()
295        continue
296      }
297      if (answered !== 'cut') {
298        // An app without the route: nothing to attach to this session.
299        if (answered.status === 404) {
300          await update($, waiting, () => [])
301          return
302        }
303        // Another process of this session holds the channel, or no hook
304        // has named the session yet.
305        if (answered.status === 409 || answered.status === 503) {
306          await $.clock.sleep(BUSY_RETRY_MS)
307          continue
308        }
309        const proof = answered.headers[PROOF_HEADER.toLowerCase()] ?? ''
310        const poll = answered.status === 200 ? parsePollAnswer(answered.text) : null
311        if (poll === null || !sameHex(proof, answerProof(link.key, nonce, answered.text))) {
312          await update($, waiting, () => [])
313          failedAt = await $.clock.now()
314          continue
315        }
316        challenge = poll.next
317        if (poll.attach !== attach) {
318          // A new attach numbers its lines from 1.
319          attach = poll.attach
320          acked = 0
321        }
322        poll.lines.forEach((line, index) => {
323          const seq = poll.first + index
324          if (seq <= acked || saidBye) return
325          acked = seq
326          const message = parseMessage(line)
327          if (message !== null && dispatch($, link, sessionID, message) === 'bye') saidBye = true
328        })
329      }
330      saidBye ||= endingSession === sessionID
331    }
332    if (!saidBye) continue
333    // Nobody tells this mod who waits until it attaches again.
334    await update($, waiting, () => [])
335    if (processEnds) return
336    const next = await newSessionID($, sessionID)
337    if (next === undefined) return
338    sessionID = next
339    attach = undefined
340    acked = 0
341  }
342}
343
344/** Waits out a failed dial, or until post.sh records a hook that reached the app. */
345async function backOff($: EngineInterface, failedAt: number): Promise<void> {
346  const runtime = await $.env.get('XDG_RUNTIME_DIR')
347  const home = await $.env.get('HOME')
348  const stamp =
349    runtime !== undefined && runtime !== ''
350      ? `${runtime}/localvoxtral/hook-status`
351      : home !== undefined && home !== ''
352        ? `${home}/.cache/localvoxtral/hook-status`
353        : undefined
354  for (;;) {
355    if ((await $.clock.now()) - failedAt >= BACKOFF_MS) return
356    if (stamp !== undefined) {
357      try {
358        const okAt = hookOKAt(await $.fs.read(stamp))
359        // The stamp counts seconds: an ok in the failure's second counts.
360        if (okAt !== undefined && okAt + 1000 > failedAt) return
361      } catch {
362        // No stamp yet.
363      }
364    }
365    await $.clock.sleep(STAMP_CHECK_MS)
366  }
367}
368
369/** The id the process went on under after `ended`, or undefined in time. */
370async function newSessionID($: EngineInterface, ended: string): Promise<string | undefined> {
371  for (let waited = 0; waited <= NEW_SESSION_WAIT_MS; waited += NEW_SESSION_POLL_MS) {
372    const id = await $.session.id()
373    if (id !== ended) return id
374    await $.clock.sleep(NEW_SESSION_POLL_MS)
375  }
376  return undefined
377}
378
379/**
380 * Tells the app the session ends (#1646), so it drops the session when the
381 * channel closes instead of waiting out a TTL. Inside `session.end`'s short
382 * budget; a failure leaves the app the session's own SessionEnd hook.
383 */
384async function sayBye($: EngineInterface, link: Link, sessionID: string): Promise<void> {
385  const bye: ChannelBye = { mod_bye: WIRE_VERSION, session_id: sessionID }
386  try {
387    await sendToApp($, link, JSON.stringify(bye), 1000)
388  } catch {
389    // The app keeps the session until its SessionEnd hook or TTL.
390  }
391}
392
393async function answer(
394  $: EngineInterface,
395  link: Link,
396  sessionID: string,
397  message: ChannelMessage,
398): Promise<void> {
399  let outcome: Outcome
400  try {
401    outcome = await handle($, sessionID, message)
402  } catch {
403    outcome = { ok: false, reason: 'failed' }
404  }
405  const reply: ChannelReply = { mod_reply: WIRE_VERSION, session_id: sessionID, id: message.id, ...outcome }
406  try {
407    await sendToApp($, link, JSON.stringify(reply), 3000)
408  } catch {
409    // The app waits out its own timeout.
410  }
411}
412
413// Every write to the prompt box, one at a time in the order the app's
414// messages arrived (#1804): a fill awaits the engine and its hooks, so a
415// send's read, fill and emptying could otherwise straddle another fill and
416// empty it, and two appends could land swapped. Only what touches the box
417// waits its turn: a submit's wait for a running turn does not.
418let box: Promise<unknown> = Promise.resolve()
419
420/** Runs `work` once every box write queued before it is done. */
421function inTurn<T>(work: () => Promise<T>): Promise<T> {
422  const done = box.then(work)
423  box = done.catch(() => {})
424  return done
425}
426
427/**
428 * Whether the process left `sessionID`: a /clear or a resume moves it to
429 * another session before `session.end` cuts this attach, and a request
430 * issued for this session must not act on that one's prompt box or
431 * transcript.
432 */
433async function moved($: EngineInterface, sessionID: string): Promise<boolean> {
434  return (await $.session.id()) !== sessionID
435}
436
437// Live Auto-Paste's deltas (#1645), filled in turn in the order they arrived.
438const stream = new AppendStream()
439// How many `cancel`s arrived: an append that arrived before the last one
440// belongs to a dictation the person threw away.
441let cancels = 0
442
443/** Queues one `append`; it is not answered, the stop's `ack` counts it. */
444function queueAppend($: EngineInterface, sessionID: string, message: ChannelMessage): void {
445  const arrivedAfter = cancels
446  void inTurn(async () => {
447    if (arrivedAfter !== cancels) {
448      stream.end()
449      return
450    }
451    if (!stream.admits(message.seq)) return
452    let isFilled = false
453    try {
454      // A delta meant for the session the process left goes nowhere.
455      if (message.text !== undefined && message.text !== '' && !(await moved($, sessionID))) {
456        isFilled = (await $.prompt.fill({ text: message.text, mode: 'insert' })).isFilled
457      }
458    } catch {
459      // Counted as not filled: the app types or keeps it and what follows.
460    }
461    stream.settle(isFilled)
462  })
463}
464
465const band = atom({ plugin: 'localvoxtral-mod', key: 'band' } as const, null)
466// The other sessions waiting for the person, oldest first (#1695).
467const waiting = atom({ plugin: 'localvoxtral-mod', key: 'waiting' } as const, [])
468let bandUpdatedAt = 0
469
470/** Shows what a `state` message says; the app waits for no answer. */
471async function showBand($: EngineInterface, message: ChannelMessage): Promise<void> {
472  const next: Band = bandOf(message)
473  const at = await $.clock.now()
474  bandUpdatedAt = at
475  await update($, band, () => next)
476  if (next !== null) {
477    $.clock.after(BAND_STALE_MS, async () => {
478      if (bandUpdatedAt === at) await update($, band, () => null)
479    })
480  }
481}
482
483/**
484 * Does what one message asks, for `sessionID` only. A kind this build does
485 * not know is not done. The kinds that write the box, and `ack`, which
486 * counts the appends before it, take their turn before any await.
487 */
488async function handle($: EngineInterface, sessionID: string, message: ChannelMessage): Promise<Outcome> {
489  const changed = { ok: false, reason: SESSION_CHANGED }
490  switch (message.kind) {
491    case 'ping':
492      return { ok: true }
493    case 'fill': {
494      const { text } = message
495      // At the cursor, as typing would put it (#1409). The app gives the
496      // text back to the keyboard on anything but ok.
497      if (text === undefined || text === '') return { ok: false, reason: 'no_text' }
498      return inTurn(async () => {
499        if (await moved($, sessionID)) return changed
500        const filled = await $.prompt.fill({ text, mode: 'insert' })
501        return filled.isFilled ? { ok: true } : { ok: false, reason: filled.refusal ?? 'refused' }
502      })
503    }
504    case 'send':
505      // An empty text submits the box as the appends left it (#1645).
506      if (message.text === undefined) return { ok: false, reason: 'no_text' }
507      return send($, sessionID, message.text)
508    case 'ack':
509      return inTurn(async () => ((await moved($, sessionID)) ? changed : { ok: true, seq: stream.ack() }))
510  }
511  if (await moved($, sessionID)) return changed
512  switch (message.kind) {
513    case 'abort': {
514      // A spoken stop phrase (#1696): ends the main loop's running turn, as
515      // Escape would, with no key. Nothing running is not an error the
516      // person needs a key for.
517      const turnId = runningTurn
518      if (turnId === undefined) return { ok: false, reason: NO_TURN }
519      await $.turn.abort({ turnId })
520      return { ok: true }
521    }
522    case 'draft':
523      // What the person already typed, for polish and the space before the
524      // fill (#1406). Read where the dictation will land, at the stop.
525      return { ok: true, ...draftOf(await $.prompt.read()) }
526    case 'terms': {
527      // The project's names, from what this session already holds (#1410):
528      // its own transcript, served from the prompt cache, no tool.
529      if (message.text === undefined || message.text === '') return { ok: false, reason: 'no_text' }
530      const forked = await $.model.fork({ prompt: message.text })
531      if (!forked.isAnswered) return { ok: false, reason: forked.reason }
532      const { input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens } = forked.usage
533      return {
534        ok: true,
535        text: forked.text,
536        usage: { input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens },
537      }
538    }
539    default:
540      return { ok: false, reason: 'unknown_kind' }
541  }
542}
543
544// The main loop's running turn, from `turn.start` to its `turn.complete`:
545// a plugin's submit waits for it.
546let runningTurn: string | undefined
547
548/**
549 * A spoken send (#1644): the text goes in at the cursor, then the box's
550 * whole text is submitted as the person's own and the box emptied, so
551 * nothing is sent twice or left behind. Answers `queued` when the submit
552 * waits for a running turn; a submit refused later puts the text back.
553 * An empty text submits the box as it stands (#1645).
554 */
555async function send($: EngineInterface, sessionID: string, text: string): Promise<Outcome> {
556  const prepared = await inTurn(async (): Promise<Outcome | string> => {
557    if (await moved($, sessionID)) return { ok: false, reason: SESSION_CHANGED }
558    const box = await $.prompt.read()
559    if (text === '' && box.text.trim() === '') return { ok: false, reason: 'no_text' }
560    const reason = needsKeys(insertedAt(box, text))
561    if (reason !== undefined) return { ok: false, reason }
562    let whole = box.text
563    if (text !== '') {
564      const filled = await $.prompt.fill({ text, mode: 'insert' })
565      if (!filled.isFilled) return { ok: false, reason: filled.refusal ?? 'refused' }
566      whole = filled.text
567    }
568    const emptied = await $.prompt.fill({ text: '', mode: 'replace' })
569    if (!emptied.isFilled) return { ok: true, submitted: false, reason: emptied.refusal ?? 'refused' }
570    return whole
571  })
572  if (typeof prepared !== 'string') return prepared
573  const whole = prepared
574
575  const busy = runningTurn !== undefined
576  const submitted = $.prompt.submit({ text: whole, asUser: true }).then(
577    (result) => (result.drop === undefined ? ('sent' as const) : putBack($, sessionID, whole)),
578    () => putBack($, sessionID, whole),
579  )
580  if (busy) return { ok: true, submitted: true, queued: true }
581  const first = await Promise.race([submitted, $.clock.sleep(SUBMIT_ANSWER_MS).then(() => 'waiting' as const)])
582  if (first === 'restored') return { ok: true, submitted: false, reason: 'dropped' }
583  if (first === 'not_restored') return { ok: true, submitted: false, reason: NOT_RESTORED }
584  return first === 'sent' ? { ok: true, submitted: true } : { ok: true, submitted: true, queued: true }
585}
586
587/**
588 * A submit that did not go: its text back in the box, after anything typed
589 * since, so neither is cut into the other. Says whether the first try put
590 * it back; a box that refused it (a dialog) is tried again a second apart
591 * (#1803). The text goes only into the box of the session it was sent
592 * from (#1802): once the process left it, or the tries ran out, it goes on
593 * the clipboard, since the box was emptied for the send and holds it
594 * nowhere else.
595 */
596async function putBack($: EngineInterface, sessionID: string, text: string): Promise<'restored' | 'not_restored'> {
597  const tryOnce = () =>
598    inTurn(async () => {
599      if (await moved($, sessionID)) return 'moved' as const
600      const box = await $.prompt.read()
601      const filled = await $.prompt.fill({ text: box.text === '' ? text : ` ${text}`, mode: 'append' })
602      return filled.isFilled ? ('restored' as const) : ('refused' as const)
603    }).catch(() => 'refused' as const)
604  const first = await tryOnce()
605  if (first === 'restored') return 'restored'
606  void (async () => {
607    let last = first
608    for (let tries = 1; last === 'refused' && tries < PUT_BACK_TRIES; tries += 1) {
609      await $.clock.sleep(PUT_BACK_RETRY_MS)
610      last = await tryOnce()
611    }
612    if (last === 'restored') return
613    const copied = await $.ui.copy({ text }).then(
614      (result) => result.isCopied,
615      () => false,
616    )
617    $.ui.toast(
618      copied
619        ? 'localvoxtral could not send your prompt or put it back in the box: it is on the clipboard.'
620        : 'localvoxtral could not send your prompt or put it back in the box.',
621    )
622  })().catch(() => {})
623  return 'not_restored'
624}
625
626// How the channel reaches the app, once `session.start` found a way.
627let channelLink: Link | undefined
628
629// Not gated on `isInteractive`, which is false for an SDK host and may be
630// for a Claude Desktop session, where the indicator and the channel matter
631// most.
632const inbox = atom({ plugin: 'localvoxtral-mod', key: 'inbox' } as const, { status: 'loading' })
633
634/**
635 * Reads this project's captures into the Inbox pane. Their words stay in the
636 * pane: none reaches the session's prompt or its model.
637 */
638async function loadInbox($: EngineInterface, cli: string | undefined): Promise<void> {
639  let view: InboxView
640  if (cli === undefined) {
641    view = { status: 'failed', reason: 'The localvoxtral command is not installed here.' }
642  } else {
643    try {
644      const run = await $.process.run([cli, 'capture', 'list', '--project', await $.session.cwd(), '--json'], {
645        timeoutMs: 5000,
646      })
647      view = inboxOf(run, await $.clock.now())
648    } catch {
649      view = { status: 'failed', reason: 'localvoxtral could not list the Inbox.' }
650    }
651  }
652  await update($, inbox, () => view)
653}
654
655/**
656 * One Inbox ask through a remote host's forward (#1412), signed like a poll:
657 * the status and body of an answer that carries the key's proof, `old` for
658 * an app without the route, or undefined.
659 */
660async function askApp(
661  $: EngineInterface,
662  link: RemoteLink,
663  path: string,
664  id?: string,
665): Promise<{ status: number; text: string } | 'old' | undefined> {
666  try {
667    const nonce = randomHex(16)
668    const request: InboxRequest = { mod_inbox: WIRE_VERSION, session_id: await $.session.id(), nonce }
669    if (id !== undefined) request.id = id
670    const body = JSON.stringify(request)
671    const asked = $.http.fetch(`http://127.0.0.1:${link.port}${path}`, {
672      method: 'POST',
673      headers: remoteHeaders(link, body),
674      body,
675    })
676    asked.catch(() => {})
677    const answered = await Promise.race([asked, $.clock.sleep(ASK_ABANDON_MS).then(() => undefined)])
678    if (answered === undefined) return undefined
679    const proof = answered.headers[PROOF_HEADER.toLowerCase()] ?? ''
680    if (sameHex(proof, answerProof(link.key, nonce, answered.text))) return { status: answered.status, text: answered.text }
681    // Unsigned, so it says nothing the pane acts on beyond this line.
682    return answered.status === 404 ? 'old' : undefined
683  } catch {
684    return undefined
685  }
686}
687
688/**
689 * Reads this session's project's captures from the app through the forward:
690 * ids, titles, kinds, states and dates; their words stay on the Mac.
691 */
692async function loadRemoteInbox($: EngineInterface, link: RemoteLink): Promise<void> {
693  const result = await askApp($, link, INBOX_PATH)
694  let view: InboxView
695  if (result === 'old') view = { status: 'failed', reason: 'Update localvoxtral on your Mac to see its Inbox here.' }
696  else if (result?.status === 200) view = inboxOf({ exitCode: 0, stdout: result.text }, await $.clock.now())
697  else if (result?.status === 409) view = { status: 'failed', reason: 'localvoxtral has not seen this session yet.' }
698  else view = { status: 'failed', reason: 'localvoxtral could not list the Inbox.' }
699  await update($, inbox, () => view)
700}
701
702/** Brings the app's Inbox forward on the capture; filing happens there. */
703async function openCapture($: EngineInterface, cli: string | undefined, id: string): Promise<void> {
704  try {
705    if (channelLink?.kind === 'remote') {
706      const result = await askApp($, channelLink, INBOX_OPEN_PATH, id)
707      if (typeof result === 'object' && result.status === 200) return
708    } else if (cli !== undefined) {
709      const { exitCode } = await $.process.run([cli, 'capture', 'open', id, '--json'], { timeoutMs: 5000 })
710      if (exitCode === 0) return
711    }
712  } catch {
713    // Said below.
714  }
715  $.ui.toast('localvoxtral could not open that capture.')
716}
717
718async function registerInbox($: EngineInterface): Promise<void> {
719  try {
720    await $.command.register({ name: 'inbox', description: "Show this project's localvoxtral captures" })
721  } catch {
722    // A host with no slash commands still gets the indicator and the channel.
723  }
724}
725
726export const register: Register = (on, options) => {
727  on('command.run', { command: 'inbox' }, async $ => {
728    await update($, inbox, () => ({ status: 'loading' }) as const)
729    await $.ui.open({ id: INBOX_PANE, title: 'Inbox' })
730    if (channelLink?.kind === 'remote') await loadRemoteInbox($, channelLink)
731    else await loadInbox($, await findCLI($, String(options.publisher_path ?? '')))
732    return { text: 'Opened the Inbox pane.' }
733  })
734
735  on('ui.render', { component: 'Pane', requestId: INBOX_PANE }, async ($, e) => {
736    const { Box, Button, Text } = $.ui.resolve(e)
737    const view = await read($, inbox)
738    if (view.status === 'loading') return <Text dimColor>Reading the Inbox…</Text>
739    if (view.status === 'failed') return <Text dimColor>{view.reason}</Text>
740    if (view.captures.length === 0) return <Text dimColor>No captures for this project.</Text>
741    const cli = channelLink?.kind === 'remote' ? undefined : await findCLI($, String(options.publisher_path ?? ''))
742    return (
743      <Box flexDirection="column">
744        {view.captures.map(capture => (
745          <Box key={capture.id} flexDirection="column" marginBottom={1}>
746            <Text>{capture.title === '' ? 'Untitled capture' : capture.title}</Text>
747            <Box>
748              <Text dimColor>{detailOf(capture, view.at)} </Text>
749              <Button
750                key={`open-${capture.id}`}
751                label="Open in localvoxtral"
752                dimColor
753                onPress={() => openCapture($, cli, capture.id)}
754              />
755            </Box>
756          </Box>
757        ))}
758      </Box>
759    )
760  })
761
762  on('turn.start', async ($, e, next) => {
763    runningTurn = e.turnId
764    return next(e)
765  })
766
767  on('turn.complete', async ($, e, next) => {
768    if (e.agentId === undefined && e.turnId === runningTurn) runningTurn = undefined
769    return next(e)
770  })
771
772  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
773    if (e.props.hasSurvey) return next(e)
774    const shown = await read($, band)
775    const columns = e.props.bodyColumns ?? 80
776    const others = waitingLine(await read($, waiting), columns)
777    if (shown === null && others === null) return next(e)
778    const { Box, Text } = $.ui.resolve(e)
779    // Two lines at most: the tail of the words, as wide as the box.
780    const room = Math.max(20, columns * 2 - 16)
781    const words = shown === null ? '' : shown.text.length > room ? `…${shown.text.slice(-(room - 1))}` : shown.text
782    return (
783      <Box flexDirection="column">
784        {shown !== null && (
785          <Box>
786            <Text color="red">● </Text>
787            <Text bold>{shown.phase === 'listening' ? 'Listening' : 'Finishing'} </Text>
788            <Text dimColor>{words}</Text>
789          </Box>
790        )}
791        {others !== null && <Text color="yellow">{others}</Text>}
792      </Box>
793    )
794  })
795
796  on('session.end', async ($, e, next) => {
797    // A turn the end cut short raises no `turn.complete` the mod sees.
798    runningTurn = undefined
799    if (channelLink !== undefined) {
800      endingSession = e.sessionId
801      processEnds = e.reason !== 'clear'
802      await sayBye($, channelLink, e.sessionId)
803      // Without the app's answer (an app that is down or predates the bye)
804      // the child would go on attaching as the cleared session: end it, so
805      // the channel moves to the new id.
806      if (!processEnds) cutChannel()
807    }
808    return next(e)
809  })
810
811  on('session.start', async ($, e, next) => {
812    const started = await next(e)
813    if ('token' in options) {
814      // The remote plugin's copy (#1412): the channel and the Inbox over the
815      // forward, and no indicator, which needs the app's binaries on this
816      // machine.
817      const remote = remoteLinkOf(options)
818      if (remote === undefined) return started
819      channelLink = remote
820      await registerInbox($)
821      runRemoteChannel($, remote).catch(() => {})
822      return started
823    }
824    await registerInbox($)
825    const publisher = await findPublisher($, String(options.publisher_path ?? ''))
826    if (publisher === undefined) return started
827    const link = { kind: 'publisher', publisher } as const
828    channelLink = link
829    // A module reload or the session's end can cut the loop mid-call.
830    runPublisherChannel($, link).catch(() => {})
831    if (await settingsShowIndicator($)) return started
832
833    const refresh = async () => {
834      try {
835        const { exitCode, stdout } = await $.process.run([publisher, '--statusline'], {
836          stdin: JSON.stringify({ session_id: await $.session.id() }),
837          env: { NO_COLOR: '1' },
838          timeoutMs: 3000,
839        })
840        const line = stdout.replace(ANSI, '').trim()
841        $.ui.status(exitCode === 0 && line !== '' ? line : undefined)
842      } catch {
843        $.ui.status(undefined)
844      }
845    }
846    await refresh()
847    $.clock.every(REFRESH_MS, refresh)
848    return started
849  })
850}
851
hooks/channel.ts 218 lines
1// The mod's end of the channel from the app (#1408; the wire is
2// Sources/ClaudeContextWire/ClaudeModChannelWire.swift). The publisher's
3// `--attach` mode holds the connection and prints each message from the app
4// as one JSON line; the mod answers each with a `--mod-reply` run. What
5// touches `$` lives in register.ts: the engine follows `$` into no import.
6
7export const WIRE_VERSION = 1
8
9export type ChannelMessage = {
10  mod_message: number
11  kind: string
12  id: string
13  text?: string
14  phase?: string
15  /** For `state`: the other sessions waiting for the person (#1695). */
16  waiting?: string[]
17  seq?: number
18}
19
20/** What a fork cost, in the API's spelling. */
21export type ChannelUsage = {
22  input_tokens: number
23  cache_creation_input_tokens: number
24  cache_read_input_tokens: number
25  output_tokens: number
26}
27
28export type ChannelReply = {
29  mod_reply: number
30  session_id: string
31  id: string
32  ok: boolean
33  reason?: string
34  text?: string
35  cursor?: number
36  usage?: ChannelUsage
37  submitted?: boolean
38  queued?: boolean
39  seq?: number
40}
41
42/** The mod's word that its session ends (#1646), sent like a reply. */
43export type ChannelBye = { mod_bye: number; session_id: string }
44
45/** Whether the mod did what a message asked, why not, and any answer. */
46export type Outcome = {
47  ok: boolean
48  reason?: string
49  text?: string
50  cursor?: number
51  usage?: ChannelUsage
52  submitted?: boolean
53  queued?: boolean
54  seq?: number
55}
56
57/** The refusal of a request issued for a session the process has left. */
58export const SESSION_CHANGED = 'session_changed'
59
60/** The refusal of an `abort` while no main-loop turn runs. */
61export const NO_TURN = 'no_turn'
62
63/**
64 * A send's reason when a hook dropped its submit and the box did not take
65 * the text back in time: the mod keeps trying, then copies it (#1803).
66 */
67export const NOT_RESTORED = 'not_restored'
68
69// How long a dropped send's text keeps trying to go back in the box, one
70// try a second: a dialog holds the box until the person closes it. After
71// that it goes on the clipboard.
72export const PUT_BACK_RETRY_MS = 1000
73export const PUT_BACK_TRIES = 120
74
75// A child that ends sooner than this after it started is a publisher that
76// does not know `--attach` (an app older than the mod): stop asking it.
77export const SHORTEST_LIFE_MS = 5000
78export const RESTART_DELAY_MS = 30000
79// After a `/clear` the process goes on under a new session id, which
80// `$.session.id()` answers only once `session.end` is over: how often and how
81// long the channel looks for it before it attaches again.
82export const NEW_SESSION_POLL_MS = 500
83export const NEW_SESSION_WAIT_MS = 10000
84// A band nobody updated for this long belongs to a dictation whose end never
85// arrived (the app quit mid-dictation): it clears itself. The app sends an
86// unchanged band again every 10 s, so a pause or a long polish keeps it.
87export const BAND_STALE_MS = 30000
88
89// How much of the draft a `draft` reply carries around the cursor, in UTF-16
90// code units: what polish reads, and far under the wire's 64 KiB line even
91// with every character escaped.
92export const DRAFT_BEFORE_CURSOR = 3000
93export const DRAFT_AFTER_CURSOR = 1000
94
95/**
96 * The prompt box as a `draft` reply carries it: the text around the cursor,
97 * cut without splitting a surrogate pair, and the cursor's offset into it.
98 */
99export function draftOf(box: { text: string; cursor: number }): { text: string; cursor: number } {
100  const cursor = Math.min(Math.max(0, box.cursor), box.text.length)
101  let start = Math.max(0, cursor - DRAFT_BEFORE_CURSOR)
102  let end = Math.min(box.text.length, cursor + DRAFT_AFTER_CURSOR)
103  if (start > 0 && isLowSurrogate(box.text.charCodeAt(start))) start += 1
104  if (end < box.text.length && isLowSurrogate(box.text.charCodeAt(end))) end -= 1
105  return { text: box.text.slice(start, end), cursor: cursor - start }
106}
107
108function isLowSurrogate(code: number): boolean {
109  return code >= 0xdc00 && code <= 0xdfff
110}
111
112// How long a `send` waits for its submit before it answers `queued`: a
113// plugin's submit resolves only once the running turn ends (measured on
114// Claude Code 2.1.287), and the app gives the reply 5 s.
115export const SUBMIT_ANSWER_MS = 1500
116
117/**
118 * Why a box cannot be submitted as typed, or undefined when it can: a
119 * plugin's submit is text alone, so a paste or image placeholder would go
120 * as its label and a `@file` mention unexpanded, and a slash command or a
121 * `!` shell line is the keyboard's to run. The app types those instead.
122 */
123export function needsKeys(box: string): string | undefined {
124  if (/\[(Pasted text|Image) #\d+/.test(box)) return 'placeholder'
125  if (/^\s*[/!]/.test(box)) return 'command'
126  if (/(^|\s)@\S/.test(box)) return 'mention'
127  return undefined
128}
129
130/** The box after `text` goes in at the cursor, as an `insert` fill puts it. */
131export function insertedAt(box: { text: string; cursor: number }, text: string): string {
132  const cursor = Math.min(Math.max(0, box.cursor), box.text.length)
133  return box.text.slice(0, cursor) + text + box.text.slice(cursor)
134}
135
136/** The band a `state` message asks for; null clears it. */
137export function bandOf(message: ChannelMessage): { phase: 'listening' | 'finishing'; text: string } | null {
138  if (message.phase !== 'listening' && message.phase !== 'finishing') return null
139  return { phase: message.phase, text: message.text ?? '' }
140}
141
142/**
143 * The band's line about the other sessions waiting for the person, at most
144 * `columns` wide; null when none does. Names only (#717).
145 */
146export function waitingLine(names: string[], columns: number): string | null {
147  if (names.length === 0) return null
148  const [first, second] = names
149  const line =
150    names.length === 1
151      ? `${first} waits for you`
152      : names.length === 2
153        ? `${first} and ${second} wait for you`
154        : `${first} and ${names.length - 1} others wait for you`
155  return line.length > columns ? `${line.slice(0, Math.max(1, columns - 1))}…` : line
156}
157
158/** Parses one line, or null for anything that is not a message of this wire. */
159export function parseMessage(line: string): ChannelMessage | null {
160  try {
161    const value: unknown = JSON.parse(line)
162    if (typeof value !== 'object' || value === null) return null
163    const { mod_message, kind, id, text, phase, waiting, seq } = value as Record<string, unknown>
164    if (mod_message !== WIRE_VERSION || typeof kind !== 'string' || typeof id !== 'string') return null
165    return {
166      mod_message,
167      kind,
168      id,
169      ...(typeof text === 'string' ? { text } : {}),
170      ...(typeof phase === 'string' ? { phase } : {}),
171      ...(Array.isArray(waiting) ? { waiting: waiting.filter(name => typeof name === 'string') } : {}),
172      ...(typeof seq === 'number' && Number.isInteger(seq) ? { seq } : {}),
173    }
174  } catch {
175    return null
176  }
177}
178
179/**
180 * One Live Auto-Paste stream's appends (#1645), in the order the app wrote
181 * them: each fills only when it is the next one, so a lost or late delta
182 * ends the stream instead of landing out of order, and so does a fill the
183 * box refused. An `ack` reads how many filled and starts the next stream.
184 */
185export class AppendStream {
186  private filled = 0
187  private ended = false
188
189  /** Whether the append numbered `seq` may fill now. */
190  admits(seq: number | undefined): boolean {
191    if (this.ended) return false
192    if (seq !== this.filled + 1) {
193      this.ended = true
194      return false
195    }
196    return true
197  }
198
199  /** What became of the append `admits` let through. */
200  settle(isFilled: boolean): void {
201    if (isFilled) this.filled += 1
202    else this.ended = true
203  }
204
205  /** Its dictation was cancelled (#1805): nothing more fills until the next `ack`. */
206  end(): void {
207    this.ended = true
208  }
209
210  /** How many filled, in order from the first; the next append starts at 1. */
211  ack(): number {
212    const filled = this.filled
213    this.filled = 0
214    this.ended = false
215    return filled
216  }
217}
218
hooks/hmac.ts 102 lines
1// HMAC-SHA256 for the remote channel's proofs (#1412). The module's
2// environment has `crypto.getRandomValues` but no `crypto.subtle.importKey`
3// (measured on Claude Code 2.1.287), so the hash is written out here.
4
5const K = new Uint32Array([
6  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
7  0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
8  0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
9  0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
10  0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
11  0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
12  0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
13  0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
14])
15
16export function sha256(data: Uint8Array): Uint8Array {
17  const length = data.length
18  const padded = new Uint8Array((((length + 9 + 63) >> 6) << 6))
19  padded.set(data)
20  padded[length] = 0x80
21  const bits = length * 8
22  const view = new DataView(padded.buffer)
23  view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000))
24  view.setUint32(padded.length - 4, bits >>> 0)
25
26  const h = new Uint32Array([
27    0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
28  ])
29  const w = new Uint32Array(64)
30  for (let block = 0; block < padded.length; block += 64) {
31    for (let i = 0; i < 16; i++) w[i] = view.getUint32(block + i * 4)
32    for (let i = 16; i < 64; i++) {
33      const a = w[i - 15]
34      const b = w[i - 2]
35      const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3)
36      const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10)
37      w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0
38    }
39    let [a, b, c, d, e, f, g, hh] = h
40    for (let i = 0; i < 64; i++) {
41      const s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7))
42      const t1 = (hh + s1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0
43      const s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10))
44      const t2 = (s0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0
45      hh = g
46      g = f
47      f = e
48      e = (d + t1) >>> 0
49      d = c
50      c = b
51      b = a
52      a = (t1 + t2) >>> 0
53    }
54    h[0] += a
55    h[1] += b
56    h[2] += c
57    h[3] += d
58    h[4] += e
59    h[5] += f
60    h[6] += g
61    h[7] += hh
62  }
63  const out = new Uint8Array(32)
64  const outView = new DataView(out.buffer)
65  for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i])
66  return out
67}
68
69export function hmacSHA256(key: Uint8Array, message: Uint8Array): Uint8Array {
70  const block = new Uint8Array(64)
71  block.set(key.length > 64 ? sha256(key) : key)
72  const inner = new Uint8Array(64 + message.length)
73  const outer = new Uint8Array(64 + 32)
74  for (let i = 0; i < 64; i++) {
75    inner[i] = block[i] ^ 0x36
76    outer[i] = block[i] ^ 0x5c
77  }
78  inner.set(message, 64)
79  outer.set(sha256(inner), 64)
80  return sha256(outer)
81}
82
83export function hex(bytes: Uint8Array): string {
84  let out = ''
85  for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
86  return out
87}
88
89/** HMAC-SHA256 of UTF-8 `message` under UTF-8 `key`, as lowercase hex. */
90export function hmacHex(key: string, message: string): string {
91  const encoder = new TextEncoder()
92  return hex(hmacSHA256(encoder.encode(key), encoder.encode(message)))
93}
94
95/** Compares two hex strings in time independent of where they differ. */
96export function sameHex(a: string, b: string): boolean {
97  if (a.length !== b.length) return false
98  let diff = 0
99  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
100  return diff === 0
101}
102
hooks/inbox.ts 53 lines
1// The Inbox pane's reading of `localvoxtral capture list --json` (#1694).
2// The CLI's JSON is the contract (Sources/ClaudeContextWire/AgentCLIWire.swift,
3// `AgentCLICaptures`); the pane shows it and never files anything: filing
4// stays in the app (#725). What touches `$` lives in register.tsx.
5
6import type { InboxCapture, InboxView } from '../types'
7
8export const INBOX_PANE = 'localvoxtral-inbox'
9
10/** The CLI's exit status when the app does not run. */
11const NOT_RUNNING = 3
12
13/** The pane's view of one `capture list` run. */
14export function inboxOf(run: { exitCode: number; stdout: string }, at: number): InboxView {
15  if (run.exitCode === NOT_RUNNING) return { status: 'failed', reason: 'localvoxtral is not running.' }
16  try {
17    const answer = JSON.parse(run.stdout) as {
18      ok?: unknown
19      captures?: { inboxAvailable?: unknown; captures?: unknown }
20    }
21    if (answer.ok !== true || typeof answer.captures !== 'object' || answer.captures === null) {
22      return { status: 'failed', reason: 'localvoxtral could not list the Inbox.' }
23    }
24    if (answer.captures.inboxAvailable !== true) return { status: 'failed', reason: 'The Inbox is not available.' }
25    const list = Array.isArray(answer.captures.captures) ? answer.captures.captures : []
26    const captures: InboxCapture[] = []
27    for (const value of list) {
28      if (typeof value !== 'object' || value === null) continue
29      const { id, title, kind, state, capturedAt } = value as Record<string, unknown>
30      const time = typeof capturedAt === 'string' ? Date.parse(capturedAt) : NaN
31      if (typeof id !== 'string' || typeof title !== 'string' || typeof state !== 'string' || Number.isNaN(time)) continue
32      captures.push({ id, title, state, capturedAt: time, ...(typeof kind === 'string' ? { kind } : {}) })
33    }
34    return { status: 'ready', captures, at }
35  } catch {
36    return { status: 'failed', reason: 'localvoxtral could not list the Inbox.' }
37  }
38}
39
40/** How long ago, as `capture list` prints it: 30m, 2h, 1d. */
41export function age(capturedAt: number, now: number): string {
42  const minutes = Math.max(0, Math.floor((now - capturedAt) / 60000))
43  if (minutes < 60) return `${minutes}m`
44  const hours = Math.floor(minutes / 60)
45  if (hours < 24) return `${hours}h`
46  return `${Math.floor(hours / 24)}d`
47}
48
49/** The dim line under a capture's title: kind, state, age. */
50export function detailOf(capture: InboxCapture, now: number): string {
51  return [capture.kind, capture.state, age(capture.capturedAt, now)].filter(part => part !== undefined).join(' · ')
52}
53
hooks/remote.ts 123 lines
1// The channel from a remote host (#1412): the same messages and replies as
2// the publisher's `--attach`, carried by a long poll on the app's listener
3// through the host's ssh forward (the wire is
4// Sources/ClaudeContextWire/ClaudeRemoteModWire.swift). What touches `$`
5// lives in register.tsx.
6//
7// The host token opens the listener's door, as it does for the command hooks.
8// A process that squats the forward port gets that token, so it proves
9// nothing about who answers: every request and every answer also carries an
10// HMAC under the host's channel key, which setup stores in the plugin's
11// config and which never crosses the tunnel.
12
13import { hmacHex } from './hmac'
14
15export const POLL_PATH = '/v1/mod/poll'
16export const REPLY_PATH = '/v1/mod/reply'
17/** A remote session's Inbox: its project's titles, and an open by id. */
18export const INBOX_PATH = '/v1/mod/inbox'
19export const INBOX_OPEN_PATH = '/v1/mod/inbox/open'
20// The app answers an Inbox ask within 5 s.
21export const ASK_ABANDON_MS = 8000
22export const PROOF_HEADER = 'X-Lvx-Mod-Proof'
23
24/** The app holds a poll this long when it has nothing to send. */
25export const POLL_HOLD_MS = 25000
26// `$.http.fetch` has no timeout: a poll the app has not answered by then is
27// abandoned as a dead forward.
28export const POLL_ABANDON_MS = 30000
29// After a failed dial: each dial at a forward with no app behind it prints a
30// `connect_to` line on the Mac's terminal, so the mod waits as long as
31// post.sh does, unless a hook reaches the app sooner.
32export const BACKOFF_MS = 300000
33export const STAMP_CHECK_MS = 5000
34// Another process of this session holds the channel, or no hook has named
35// the session yet: ask again later.
36export const BUSY_RETRY_MS = 10000
37
38/** The port the forward binds on this host, by post.sh's rule. */
39export function remotePort(raw: unknown): number {
40  const text = typeof raw === 'string' ? raw : ''
41  if (!/^[1-9][0-9]{0,4}$/.test(text)) return 8473
42  const port = Number(text)
43  return port >= 1024 && port <= 65535 ? port : 8473
44}
45
46/** A host token as the app mints it (base64url, 16 to 128 characters). */
47export function isToken(value: unknown): value is string {
48  return typeof value === 'string' && /^[A-Za-z0-9_-]{16,128}$/.test(value)
49}
50
51/** A channel key as setup stores it: 64 lowercase hex digits. */
52export function isChannelKey(value: unknown): value is string {
53  return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value)
54}
55
56/** The proof a request body carries. */
57export function requestProof(key: string, body: string): string {
58  return hmacHex(key, `lvx-mod-request-v1\n${body}`)
59}
60
61/** The proof the app's answer to a poll carries, bound to the poll's nonce. */
62export function answerProof(key: string, nonce: string, body: string): string {
63  return hmacHex(key, `lvx-mod-answer-v1\n${nonce}\n${body}`)
64}
65
66export type PollRequest = {
67  mod_poll: number
68  session_id: string
69  /** This load of the module: a second process of the session is refused. */
70  instance: string
71  nonce: string
72  /**
73   * The `next` of the last answer this mod verified, or empty: only a poll
74   * carrying one the app issued and nobody used gets lines, so a poll a
75   * squatter captured cannot be replayed.
76   */
77  challenge: string
78  /** The attach `acked` counts in; 0 before the first. */
79  attach: number
80  /** The last line of that attach delivered; the app drops it and those before. */
81  acked: number
82}
83
84export type InboxRequest = { mod_inbox: number; session_id: string; nonce: string; id?: string }
85
86/**
87 * One poll's answer: the attach it belongs to, the number of its first line,
88 * the lines, each one message as the publisher would print it, and the
89 * challenge the next poll carries.
90 */
91export type PollAnswer = { attach: number; first: number; lines: string[]; next: string }
92
93export function parsePollAnswer(text: string): PollAnswer | null {
94  try {
95    const value: unknown = JSON.parse(text)
96    if (typeof value !== 'object' || value === null) return null
97    const { attach, first, lines, next } = value as Record<string, unknown>
98    if (!Number.isInteger(attach) || !Number.isInteger(first) || !Array.isArray(lines)) return null
99    if (!lines.every(line => typeof line === 'string')) return null
100    if (typeof next !== 'string' || !/^[0-9a-f]{32}$/.test(next)) return null
101    return { attach: attach as number, first: first as number, lines: lines as string[], next }
102  } catch {
103    return null
104  }
105}
106
107/**
108 * When post.sh last reached the app, from its `hook-status` stamp
109 * (`ok <epoch seconds>`), in milliseconds; undefined for any other state.
110 */
111export function hookOKAt(stamp: string): number | undefined {
112  const match = /^ok ([0-9]{1,12})\s*$/.exec(stamp)
113  return match === null ? undefined : Number(match[1]) * 1000
114}
115
116/** `bytes` random bytes as hex. */
117export function randomHex(bytes: number): string {
118  const values = crypto.getRandomValues(new Uint8Array(bytes))
119  let out = ''
120  for (const value of values) out += value.toString(16).padStart(2, '0')
121  return out
122}
123
types/index.d.ts 18 lines
1/** What the band above the prompt shows (#1411); null draws nothing. */
2export type Band = { phase: 'listening' | 'finishing'; text: string } | null
3
4/** One capture as the Inbox pane lists it (#1694). */
5export type InboxCapture = { id: string; title: string; kind?: string; state: string; capturedAt: number }
6
7/** What the Inbox pane draws. */
8export type InboxView =
9  | { status: 'loading' }
10  | { status: 'ready'; captures: InboxCapture[]; at: number }
11  | { status: 'failed'; reason: string }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'localvoxtral-mod': { band: Band; waiting: string[]; inbox: InboxView }
16  }
17}
18