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…

<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> <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> <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.
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.
--force, "use auth dot t s" becomes useAuth.ts (details).[!TIP] If localvoxtral is useful to you, a ⭐ on this repo helps others find it.
Every guide is listed in the documentation index.
hooks/register.tsx 851 lines1import { 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}
851hooks/channel.ts 218 lines1// 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}
218hooks/hmac.ts 102 lines1// 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}
102hooks/inbox.ts 53 lines1// 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}
53hooks/remote.ts 123 lines1// 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}
123types/index.d.ts 18 lines1/** 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