Full-duplex voice conversation with your running Claude Code session, powered by OpenAI gpt-live-1. Toggle with /talk.

Talk to your running Claude Code session out loud, hands-free, in both directions at once.
<img src="docs/images/sotto-light.png" alt="The Sotto desktop panel during a live session: the voice is speaking, so the lit string running from the microphone button across the panel ripples with it and the caption line shows its words; below, Claude is working and its messages fill a scrolling page, older ones dimmed above the newest; the footer holds the persona, microphone, speaker, pause, end-voice and settings buttons" width="360">
Sotto is a Claude Code plugin. /talk opens a small voice window. The voice in that window is OpenAI's gpt-live-1 (the Live API, not the older Realtime API). It handles the conversation itself: it listens, backchannels, lets you interrupt, and answers small talk. When you ask for something that needs the code, it hands the request to your Claude Code session as if you had typed it. When Claude finishes, the voice tells you the result in its own words. Claude keeps working in the terminal the whole time, and you can keep typing there too.
you (speaking) ──▶ gpt-live-1 ──delegates──▶ your Claude Code session ──result──▶ gpt-live-1 ──▶ you (hearing)
/talk is handled by a hook that stops the prompt, so turning voice on or off costs no tokens.Sotto is an independent open-source project. It is not made, endorsed or supported by Anthropic or OpenAI. "Claude" and "Claude Code" are trademarks of Anthropic; "OpenAI" and "GPT" are trademarks of OpenAI.
MessageDisplay and UserPromptExpansion hooks). 2.1.287 or later is better: Sotto's Claude Code mod then puts voice messages straight into the session (as your own prompt when Claude is idle, into the running turn when it is busy), with nothing held and exact turn state. Everywhere the mod does not run (an older CLI, --bare, --safe-mode, mods turned off by you or your organization, or the mod failing), Sotto falls back by itself, even mid-session, to its classic hooks and courier, which work as before. /talk status ends with link mod or link classic.PATH. There are no npm dependencies. If your node is older (or missing), Bun 1.1 or later works too: /talk uses it automatically, and tells you how to install one if neither is there.gpt-live-1. Voice is billed by OpenAI to that key (see Cost).claude plugin marketplace add chadboyda/sotto
claude plugin install sotto@sotto
Then start (or /reload-plugins in) a Claude Code session and run /talk. That's it: on the first run there is no key yet, so the voice window opens at Add your OpenAI API key to start.
gpt-live-1 (a project with "All" model access is; a restricted project needs gpt-live-1 enabled), and the account needs billing set up.gpt-live-1) and tells you plainly if it isn't: a wrong or revoked key, a project without gpt-live-1, or no network.sotto, account openai-api-key) and voice connects right away.The window never shows the key again, only "Key ending in abcd". To change or remove it, open the window's settings (the gear) → OpenAI API key → Change or Remove; this works in Chrome and in the desktop app. /talk key tells you which key is in use and where it comes from. Never paste a key into the Claude Code prompt: /talk key sk-... does not read it (it opens the window instead), and whatever you type there stays in your prompt history.
Other ways to provide the key. The daemon uses the first key it finds, in this order:
| Where | How |
|---|---|
| 1. The environment | export OPENAI_API_KEY=sk-... before starting Claude Code |
2. A .env file | OPENAI_API_KEY=sk-... in <plugin dir>/.env, <plugin data dir>/.env or ~/.sotto/.env (chmod 600 it) |
| 3. The macOS Keychain | what the voice window saves; or from a terminal: security add-generic-password -U -s sotto -a openai-api-key -w (it prompts for the key, so it stays out of your shell history) |
| 4. The plugin settings | the optional openai_api_key field Claude Code asks for when you enable the plugin; Claude Code keeps it in its own secure storage. It reaches a voice daemon when one starts. |
The key only ever goes from the daemon to OpenAI: it is never logged, never sent to the voice page (only its last four characters), never put on a command line, and Sotto never writes it to a file.
git clone https://github.com/chadboyda/sotto.git ~/dev/sotto
cd ~/dev/sotto
echo 'OPENAI_API_KEY=sk-...' > .env && chmod 600 .env # .env is git-ignored
ln -s ~/dev/sotto ~/.claude/skills/sotto
The symlink loads the plugin in place as sotto@skills-dir, so edits to the repo apply on /reload-plugins or the next session. Disable it with claude plugin disable sotto@skills-dir. To try it for one session without installing anything: claude --plugin-dir ~/dev/sotto.
| Command | Effect | ||
|---|---|---|---|
/talk | Toggle voice for this session | ||
/talk on | Turn voice on here (or move it here from another session) | ||
/talk off | Turn voice off, from any session | ||
/talk status | State, owner project, minutes and cost today, voice, persona, policy, last error | ||
/talk restart | Restart the voice daemon on the latest code at the next pause (see "Updates" below) | ||
/talk quiet / milestones / walkthrough | Change how much the voice narrates (see below) | ||
/talk voice | List the 22 voices, with the current one marked | ||
/talk voice <name> | Change the voice, for example /talk voice cedar. If voice is live, it switches right away (see below). The choice is saved and survives restarts. | ||
/talk persona | List the personas (built-in and your own), with the current one marked | ||
/talk persona <name> | Change the voice's personality, for example /talk persona moss. Switches right away if voice is live, and is saved like the voice (see Personas) | ||
/talk app | Use the Sotto desktop app: install it now if it is missing (or retry a failed install), open the voice in it, and remember the choice | ||
| `/talk window <auto\ | app\ | chrome>` | Where the voice window opens, saved like the voice. /talk window alone shows the current choice and whether the app is installed |
| `/talk cap <off\ | minutes\ | hours h>` | The daily voice limit, for example /talk cap off (unlimited) or /talk cap 4h. Applies at once and resumes a voice paused on the limit; sotto cap and Settings > Daily limit do the same. /talk cap alone shows it and today's use |
/talk key | Which OpenAI API key is in use (its last four characters) and where it comes from; with no key, opens the window to add one |
/sotto:talk … is the same command with its full name.
When voice is on:
[sotto voice <code>] … (the code changes each time voice is turned on, so look-alike messages are not treated as your speech), and the voice says it has passed it on.In the voice window: one lit string runs from the microphone button (the peg; click it or press M or Space to mute) across the panel and moves with whoever is talking, and the line under it shows the latest words, yours or the voice's. Below that is Claude's page: its messages for this turn and the recent ones, in a region that scrolls and follows the newest words (scroll up to read back; "Jump to latest" brings you down again). When Claude needs an approval, the question takes that page inside a gold frame. The footer has the persona (it opens Settings), the microphone and speaker in use (click either to switch devices), Pause, End voice and Settings, where the voice, persona, wake sensitivity and echo tools live. Space resumes after a pause.
Sleep and wake. After a minute with nobody talking (and nothing pending for Claude), the paid Live session closes and the window shows "Sleeping — just start talking". The mic stays open locally: a small voice detector in the page listens, nothing is sent and nothing is billed. When you speak again, a new session starts in about 1.2 s with the conversation so far; the words you said before it connected are transcribed and handed to the model, so it answers the whole sentence. When Claude finishes a voice request or needs your approval while voice sleeps, it wakes up to tell you. M while sleeping stops listening; the Wake picker sets sensitivity (Off = click or Space to resume).
On macOS the voice window is a small native app, Sotto (SwiftUI, native Core Audio): a menu-bar icon plus a floating panel with the same states and controls as the Chrome page. It stays on top on every Space without taking focus from your terminal. The app only carries audio and shows state; the daemon holds the Live session, so the Chrome page remains a drop-in fallback (/talk window chrome).
/talk starts the install in the background. It downloads the signed app for this plugin version from the GitHub releases: about 2.3 MB, universal, signed with a Developer ID and notarized by Apple. That takes a few seconds, and the voice window waits for it (up to 15 s), so the first /talk normally opens the app already. If the install takes longer, this /talk uses Chrome and the next one opens the app./talk app. It installs the app if it is missing (or retries a failed install), opens the voice in it, and saves window = app.app-native/ sources, it is code-signed by team 6M6D2W72ZB and Gatekeeper accepts its notarization.app-native/, or there is no release for this version, the plugin builds the app locally. That takes 10 to 20 s and needs Xcode or the Command Line Tools (xcode-select --install).app/Sotto.app) and is replaced when the plugin's app-native/ sources change. Every step is logged to logs/app-build.log./talk, /talk status and the voice window say "Desktop app couldn't be installed: <reason>", and voice uses Chrome. SOTTO_APP_DOWNLOAD=0 skips the download and always builds locally.defaults write com.chadboyda.sotto HotkeyMute "ctrl+opt+m" (or HotkeyShow), then relaunch the app. Use ctrl, opt, shift, cmd plus a letter, digit, space or f1-f12./talk off ends it. When voice goes off the app quits, unless you tick "Stay in Menu Bar When Voice Is Off"./talk window <auto|app|chrome> or /talk app, both saved in prefs.json. There is also the window option in /config (auto, app, chrome, default), and SOTTO_BROWSER beats both.Changing the voice. gpt-live-1 fixes the voice when a session starts, so a change re-creates the Live session: the old one closes, the window connects a new one seeded with the recent conversation, and the new voice says "Switched to cedar." It takes about a second. Three ways to do it:
/talk voice cedar (no model turn);sotto voice cedar. The plugin's bin/ is on Claude's Bash PATH. Claude Code may ask you once to approve that command; add Bash(sotto voice:*) to your allow rules to skip the prompt;The choice is saved in prefs.json in the data directory. It beats the voice option in /config, which beats the default (marin).
The voices. The picker groups them by presentation and shows what each sounds like under its name; sotto voice lists the same descriptions, so you can ask for "something warmer" or "a British voice" and Claude can pick one. Presentation and accent follow OpenAI's voice table for the twelve voices it lists; the others were judged by ear and pitch, and the two that sit in between are marked androgynous.
| Voice | Sounds | Presentation | Accent |
|---|---|---|---|
alloy | Smooth, clear, even | androgynous | American |
ash | Clear, crisp, steady | masculine | American |
ballad | Warm, easygoing, lightly breathy | masculine | American |
beacon | Clean, crisp, articulate | masculine | Filipino |
bossa | Soft, breathy, gentle | feminine | Brazilian |
cedar | Relaxed, textured, casual | masculine | American |
cinder | Deep, calm, grounded | masculine | Southern US |
coral | Bright, lively, upbeat | feminine | American |
delta | Bright, crisp, friendly | feminine | Southern US |
echo | Smooth, warm, low | masculine | American |
gleam | Cheerful, smooth, warm | feminine | North American |
marin | Bright, clear, polished | feminine | American |
meridian | Deep, clear, easygoing | masculine | North American |
quartz | Bright, airy, buoyant | feminine | Australian |
ripple | Smooth, dry, relaxed | masculine | Australian |
sage | Bright, clear, measured | feminine | American |
shimmer | Crisp, smooth, calm | lower, androgynous | American |
stone | Deep, relaxed, grounded | masculine | Irish |
tempo | Easygoing, smooth, low | masculine | Brazilian |
verse | Clear, relaxed, a little gravel | masculine | American |
vesper | Dry, low-key, grounded | masculine | British |
willow | Bright, crisp, warm | feminine | Irish |
Updates. When the plugin's code changes on disk (a git pull in the plugin directory, or your own edits), the daemon notices within about 30 s and restarts itself at the next quiet moment: while voice sleeps, or after 45 s with nobody talking and nothing pending for Claude. It never restarts mid-sentence or while Claude works on a voice request. The new daemon takes over the same session, the window reconnects (and reloads if the page changed), and if you were mid-conversation the voice says "I just updated myself" and carries on with what you were talking about. Code that does not load is never switched to. /talk restart does the same right away (at the next short pause).
To hear a voice before switching, open Hear the voices under the picker and click a name. The live session keeps its voice. The first sample of a voice takes about 4 seconds: a tiny separate Live session says "Hi, I'm Cedar. This is how I sound." (a few billed seconds, well under a cent, counted in today's usage). After that the sample is saved in voice-previews/ in the data directory and plays at once.
Only one session owns voice at a time. /talk on in another session moves voice there, and the voice tells you it switched projects. Voice turns itself off when the owning session exits.
The voice is a relay for your coding session, not its memory. It is told to hand Claude anything that decides, asks for, corrects or reports something, even in passing: "Sotto is a good name, let's use it", "let me know when that's merged", "that looks like a bug", your answer to a question Claude asked. It must not claim to have noted or scheduled anything itself; it says "I'll pass that to Claude" and does.
As a safety net, whatever you said that the voice did not hand over still reaches Claude about 6 s after you stop talking, as one message marked (said to the voice assistant, not delegated), queued behind anything Claude is doing. Claude treats it as information: it acts on decisions and requests in it, answers project questions briefly, and otherwise replies just "Noted." (which the voice keeps to itself). Filler ("okay, cool", "thanks"), requests about the voice itself ("slow down", "say that again") and echoes of the voice are never forwarded, and nothing is sent twice. The mirror option picks what is forwarded: all (default), decisions (only decisions, feedback and requests), or off.
When Claude ends a turn with a question or a list of options, or asks one with AskUserQuestion, the voice is told Claude is waiting, so your next words count as the answer and go to Claude.
You can interrupt the voice at any time, on headphones or speakers: it is a full-duplex conversation, and Sotto never mutes you while the voice talks. On speakers, three things keep the voice from hearing itself:
echo_guard option: auto (default), on, off.Test echo in the settings (the gear) plays a short sound on the selected speaker and tells you Good, Some echo or Heavy echo. In Chrome it also tries Chrome's stronger echo cancellation mode and keeps whichever works better on that speaker. The first time you use a new speaker, a quiet version runs once by itself while connecting. Headphones need none of this.
| Policy | The voice speaks… |
|---|---|
quiet | Only what needs you: answers to what you asked by voice, Claude's questions, approvals, plan reviews, MCP input requests, and errors. |
milestones (default) | All of that, plus a one-sentence "finished" for turns you typed, subagent and background-task completions, and a progress note at most every 30 s in a long turn. |
walkthrough | Also narrates Claude's progress as it works, task checklist ticks, teammates going idle, and failed tool steps. |
What gets said, per event:
| Event | quiet | milestones | walkthrough |
|---|---|---|---|
| Answer to your voice request | spoken | spoken | spoken, longer |
Claude asks you a question (AskUserQuestion): "Claude's asking: which layout? Options: A, B, or C. Answer in the terminal." | spoken | spoken | spoken |
Plan ready for approval (ExitPlanMode) | spoken (title) | spoken (title) | spoken (title and gist) |
Tool approval (PermissionRequest; the later permission_prompt notification is not repeated). A background agent's approval says so. Every approval is spoken, and the question clears from the panel as soon as it is answered | spoken | spoken | spoken |
| An approval still waiting after 2 and 5 minutes: "By the way, Claude's still waiting on your approval to run a shell command." (Claude Code never times one out) | spoken | spoken | spoken |
MCP server needs input (Elicitation), a background session needs input, usage limit reset and waiting for Enter | spoken | spoken | spoken |
Claude Code hit an API error (StopFailure) | spoken | spoken | spoken |
| Background work you asked for by voice finished (its task-notification turn) | spoken | spoken | spoken |
| Turn you typed finished | silent note | one sentence | summary |
| Background work from a typed turn finished | silent note | one sentence | two sentences |
Subagent finished (SubagentStop), batched over 3 s | silent note | spoken | spoken |
Claude is idle and waiting (idle_prompt), once per idle period, only if its result wasn't already spoken | silent note | spoken | spoken |
| Intermediate progress |
hooks/sotto-mod.mjs 540 lines1// Sotto's Claude Code mod (SPEC §6.21). Claude Code 2.1.287 and later load
2// it from hooks.json's "modules"; older CLIs ignore that key and run the
3// classic command hooks beside it in the same file, so the plugin works
4// there exactly as before (verified on 2.0.77, 2.1.200, 2.1.285-2.1.287).
5//
6// It is inert (one boolean check per hook) until the session it runs in owns
7// voice: D/active names this session's inbox socket. Then it says hello to the
8// daemon and carries the session's side:
9// - downlink: a long-poll loop (holds <= 20 s: $.http.fetch has a hard 30 s
10// cap) brings voice messages and the voice's state; idle, a message is
11// submitted as the user's own prompt; while Claude works, it is appended to
12// the running turn, and an append the model never read (it landed after the
13// last request) is reported so the daemon requeues it; mirrors wait for the
14// end of the turn.
15// - uplink: the classic hook payloads (PreToolUse adapted to the stdin shape),
16// main-thread turn start/complete, receipts, the conversation for the seed,
17// subagent spawns (who launched them) and tool approvals, in order,
18// seq-numbered, batched.
19// - /talk: run here (toggle.sh under $.process.run, the daemon cold-started
20// from the mod) once the data dir is known; the classic expansion otherwise.
21// - model tools mcp__sotto__voice / persona / status while linked.
22// - terminal UI: one band above the prompt (no status line).
23// The daemon then writes "mod:<socket>" into D/active, which makes hook.sh a
24// no-op for this session. If this mod goes quiet the daemon falls back to the
25// shell hooks and the courier on its own; if the daemon goes away this mod
26// unlinks and looks again later.
27//
28// Rules for this file (claude plugin validate): spell every $ call in full,
29// pass $ only to top-level functions of this file, string-literal event and
30// env names, one hook per event without a matcher. No await on the network
31// inside a hook except a tool or command it answers itself: the loops wait.
32import {
33 FORWARDED, adaptPreToolUse, baseOf, forwardBody, markerFor, parseActive, ownsSession, Seen, Uplink, Delivery, appendText,
34 optionEnv, isTalkCommand, transcriptPathFor, expansionInput, toggleText, contextOf, bandRows,
35} from "./modcore.mjs";
36
37const MOD_VERSION = "2";
38const BATCH_MS = 30;
39const SUBMIT_WAIT_MS = 15000;
40const TOOL_PREFIX = "mcp__sotto__";
41const STORE_DIR = "dataDir"; // $.store key prefix: the daemon data dir of a plugin root
42
43let options = {};
44let instance = "";
45let link = null; // {base, key, socket, marker, context}
46let gen = 0;
47let after = 0;
48let sv = 0; // the UI state version this mod has
49let ui = null; // the voice's state, for the status line and the band
50let discovering = false;
51let draining = false;
52let pumping = false;
53let toolsUp = false;
54let base = {};
55let kick = null; // wakes the drain loop when an event is queued
56let promptId = null;
57const asks = new Set(); // tool_use_ids tool.check put to the user
58const seen = new Seen();
59const up = new Uplink();
60const del = new Delivery();
61const FORWARD = new Set(FORWARDED);
62
63function newInstance() {
64 return `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
65}
66
67function wait($, ms) {
68 return new Promise((resolve) => { $.clock.after(ms, resolve); });
69}
70
71function headers(json) {
72 const h = { "X-Sotto-Key": link ? link.key : "" };
73 if (json) h["Content-Type"] = "application/json";
74 return h;
75}
76
77function wakeDrain() {
78 const k = kick;
79 kick = null;
80 if (k) k();
81}
82
83function unlink($) {
84 link = null;
85 gen++;
86 up.clear();
87 del.queue = [];
88 asks.clear();
89 wakeDrain();
90 if (ui) {
91 ui = null;
92 $.ui.invalidate("ui.render");
93 }
94}
95
96async function integrationOff($) {
97 const env = await $.env.get("SOTTO_INTEGRATION");
98 const v = env || options.integration || "auto";
99 return v === "classic" || v === "off";
100}
101
102async function configDir($) {
103 const home = await $.env.get("HOME");
104 return (await $.env.get("CLAUDE_CONFIG_DIR")) || (home ? `${home}/.claude` : "");
105}
106
107async function dataDirs($) {
108 const forced = await $.env.get("SOTTO_DATA_DIR");
109 if (forced) return [forced];
110 const home = await $.env.get("HOME");
111 const cfg = await configDir($);
112 const out = [];
113 if (cfg) {
114 try {
115 for (const ent of await $.fs.list(`${cfg}/plugins/data`)) {
116 if (ent.name.startsWith("sotto")) out.push(`${cfg}/plugins/data/${ent.name}`);
117 }
118 } catch { /* no plugin data yet */ }
119 }
120 if (home) out.push(`${home}/.sotto`);
121 return out;
122}
123
124/** Find the daemon whose D/active names this session; say hello; start the loops. */
125async function discover($, why) {
126 if (link || discovering) return;
127 discovering = true;
128 try {
129 if (await integrationOff($)) return;
130 const socket = await $.env.get("CLAUDE_CODE_MESSAGING_SOCKET");
131 if (!socket) return;
132 for (const dir of await dataDirs($)) {
133 let a = null;
134 try { a = parseActive(await $.fs.read(`${dir}/active`)); } catch { continue; }
135 if (!a || !ownsSession(a.owner, socket)) continue;
136 const url = `http://127.0.0.1:${a.port}`;
137 let sessionId = null, cli = null;
138 try { sessionId = await $.session.id(); } catch { /* none */ }
139 try { cli = (await $.session.version()).version; } catch { /* none */ }
140 let r;
141 try {
142 r = await $.http.fetch(`${url}/mod/hello`, {
143 method: "POST",
144 headers: { "Content-Type": "application/json", "X-Sotto-Key": a.key },
145 body: JSON.stringify({ instance, socket, session_id: sessionId, cli, mod: MOD_VERSION, why }),
146 });
147 } catch { continue; }
148 if (r.status !== 200) continue;
149 let b = {};
150 try { b = JSON.parse(r.text); } catch { continue; }
151 const marker = markerFor(b.nonce || a.nonce);
152 let context = "";
153 try { context = String(await $.fs.read(`${$.plugin.root}/scripts/voice-context.txt`)).trim().split("@MARKER@").join(marker); } catch { /* framing is optional */ }
154 link = { base: url, key: a.key, socket, marker, context };
155 gen++;
156 after = Number.isFinite(b.after) ? b.after : 0;
157 sv = 0;
158 // Where this plugin's daemon keeps its data, for /talk from the mod later.
159 if (typeof b.data_dir === "string" && b.data_dir) {
160 try { await $.store.set(`${STORE_DIR}:${$.plugin.root}`, b.data_dir); } catch { /* the classic /talk still works */ }
161 }
162 void pollLoop($, gen);
163 void drain($, gen);
164 void pushContext($);
165 void registerTools($);
166 return;
167 }
168 } catch { /* stay classic */ } finally {
169 discovering = false;
170 }
171}
172
173/** Downlink: long-poll the daemon for voice messages and UI state. */
174async function pollLoop($, g) {
175 let fails = 0;
176 while (link && g === gen) {
177 let r = null;
178 try {
179 r = await $.http.fetch(`${link.base}/mod/poll?instance=${instance}&after=${after}&sv=${sv}`, { headers: headers(false) });
180 } catch { r = null; } // refused (daemon restarting) or the host's 30 s cap
181 if (g !== gen || !link) return;
182 if (!r) {
183 fails++;
184 if (fails >= 4) { unlink($); return; }
185 await wait($, 250 * 2 ** fails);
186 continue;
187 }
188 fails = 0;
189 if (r.status === 410 || r.status === 409 || r.status === 403) {
190 // Replaced, released or a new daemon process: look again.
191 unlink($);
192 void discover($, "relink");
193 return;
194 }
195 if (r.status !== 200) { await wait($, 1000); continue; }
196 let b;
197 try { b = JSON.parse(r.text); } catch { continue; }
198 if (b.release) { unlink($); return; }
199 if (Number.isFinite(b.sv)) sv = b.sv;
200 if (b.state) applyState($, b.state);
201 for (const it of Array.isArray(b.items) ? b.items : []) {
202 if (Number.isFinite(it.seq)) after = Math.max(after, it.seq);
203 await onItem($, it);
204 }
205 }
206}
207
208/**
209 * The voice's state: a redraw of the band. The band is Sotto's only surface in
210 * the terminal; a status line beside it showed Sotto twice (and Claude Code
211 * draws a plugin's status line as a warning).
212 */
213function applyState($, state) {
214 ui = state;
215 $.ui.invalidate("ui.render");
216}
217
218function receipt(msgId, how, extra = {}) {
219 push({ kind: "receipt", msg_id: msgId, how, ...extra });
220}
221
222/** One voice message from the daemon. */
223async function onItem($, it) {
224 if (!it || it.kind !== "inject" || typeof it.text !== "string" || typeof it.msg_id !== "string") return;
225 if (seen.has(it.msg_id)) { receipt(it.msg_id, "duplicate"); return; }
226 seen.add(it.msg_id);
227 const where = del.route(it);
228 if (where === "append") {
229 let r = null;
230 try {
231 r = await $.session.append({ message: { type: "user", content: [{ type: "text", text: appendText(it.text, link ? link.context : "") }] } });
232 } catch (err) { r = { deny: String(err) }; }
233 if (r && r.deny) {
234 // Refused (a guard above): it waits for the end of the turn instead.
235 del.enqueue(it);
236 receipt(it.msg_id, "queued", { note: "append_refused", error: String(r.deny).slice(0, 200) });
237 return;
238 }
239 del.noteAppend(it.msg_id);
240 receipt(it.msg_id, "appended", { prompt_id: promptId });
241 $.ui.log("sotto: a voice message was added to Claude's running turn");
242 return;
243 }
244 del.enqueue(it);
245 if (where === "queue") receipt(it.msg_id, "queued");
246 void pump($);
247}
248
249/** Submit queued messages one at a time, only while the session is idle. */
250async function pump($) {
251 if (pumping) return;
252 pumping = true;
253 try {
254 let it;
255 while (link && (it = del.nextQueued())) {
256 // It resolves once its turn started; never wait on it unbounded (a
257 // submit merged into another's turn never resolves).
258 const out = await Promise.race([
259 $.prompt.submit({ text: it.text, asUser: true }).then(() => "ok", (err) => `error ${err}`),
260 wait($, SUBMIT_WAIT_MS).then(() => "timeout"),
261 ]);
262 if (out.startsWith("error")) receipt(it.msg_id, "failed", { error: out.slice(6), text: it.text, priority: it.priority });
263 else {
264 receipt(it.msg_id, "submitted", out === "timeout" ? { note: "unconfirmed" } : {});
265 $.ui.log("sotto: a voice message was sent as your prompt");
266 }
267 // Its turn.start set busy; the rest waits for turn.complete.
268 }
269 } finally {
270 pumping = false;
271 }
272}
273
274function push(ev) {
275 if (!link) return;
276 up.push(ev);
277 wakeDrain();
278}
279
280/** Uplink: POST the queued events in order, in batches. */
281async function drain($, g) {
282 if (draining) return;
283 draining = true;
284 try {
285 let fails = 0;
286 while (link && g === gen) {
287 if (!up.size) { await new Promise((resolve) => { kick = resolve; }); continue; }
288 await wait($, BATCH_MS); // let a burst (MessageDisplay deltas) collect
289 if (!link || g !== gen) return;
290 const events = up.batch(50);
291 let r = null;
292 try {
293 r = await $.http.fetch(`${link.base}/mod/events`, { method: "POST", headers: headers(true), body: JSON.stringify({ instance, events }) });
294 } catch { r = null; }
295 if (g !== gen || !link) return;
296 if (!r) { fails++; await wait($, Math.min(5000, 200 * 2 ** fails)); continue; }
297 fails = 0;
298 if (r.status === 410 || r.status === 409 || r.status === 403) { unlink($); void discover($, "relink"); return; }
299 if (r.status !== 200) { await wait($, 1000); continue; }
300 try { up.ack(JSON.parse(r.text).acked); } catch { /* resent next round; the daemon skips seen seqs */ }
301 }
302 } finally {
303 draining = false;
304 }
305}
306
307function forward(name, e) {
308 if (!link) return;
309 base = baseOf(e, base);
310 push({ kind: "classic", event: name, body: forwardBody(name, e) });
311}
312
313/** The conversation as the session holds it, for the next Live session's seed. */
314async function pushContext($) {
315 if (!link) return;
316 try { push({ kind: "context", messages: contextOf(await $.session.messages()) }); } catch { /* the transcript file is the fallback */ }
317}
318
319/** The model's voice controls, from the first link on (the same as `sotto voice|persona|status`). */
320async function registerTools($) {
321 if (toolsUp) return;
322 toolsUp = true;
323 const name = { type: "object", properties: { name: { type: "string", description: "The name to switch to; leave it out to list the choices." } } };
324 try {
325 await $.tool.register({ name: "voice", description: "Sotto (the user's spoken voice assistant): switch the voice it speaks with, or list the voices. Use it only when the user's own words clearly ask for a different voice; a yes to a voice the voice assistant offered counts.", inputSchema: name });
326 await $.tool.register({ name: "persona", description: "Sotto (the user's spoken voice assistant): switch its persona (its personality), or list the personas with a line each. Use it only when the user clearly asks for a different persona or personality.", inputSchema: name });
327 await $.tool.register({ name: "status", description: "Sotto (the user's spoken voice assistant): its state, voice, persona and today's usage, in one line.", inputSchema: { type: "object", properties: {} } });
328 } catch { toolsUp = false; }
329}
330
331/** A voice tool call: the daemon's /control, as bin/sotto sends it. */
332async function answerTool($, e) {
333 const action = String(e.tool).slice(TOOL_PREFIX.length);
334 if (!link) return { result: "sotto: voice is off in this session. The user turns it on with /talk." };
335 const arg = typeof e.name === "string" ? e.name.trim().toLowerCase() : "";
336 if (arg && !/^[a-z0-9_-]{1,40}$/.test(arg)) return { result: `sotto: unknown ${action}.` };
337 const body = { action, via: "cli", session: { socket: link.socket } };
338 if (action === "voice") { body.voice = arg; body.confirm = !!arg; }
339 if (action === "persona") { body.persona = arg; body.confirm = !!arg; }
340 try {
341 const r = await $.http.fetch(`${link.base}/control`, { method: "POST", headers: headers(true), body: JSON.stringify(body) });
342 const j = JSON.parse(r.text);
343 return { result: typeof j.message === "string" ? j.message : "sotto: no answer from the voice daemon." };
344 } catch (err) {
345 return { result: `sotto: ERROR the voice daemon did not answer (${String(err).slice(0, 120)}).` };
346 }
347}
348
349/**
350 * /talk from the mod (phase 4): toggle.sh itself, run with the environment
351 * Claude Code gives the classic hook (the data dir remembered from the
352 * daemon's hello), so the messages, the daemon cold start and every argument
353 * are the same. null: not possible here, the classic expansion runs it.
354 */
355async function talk($, e) {
356 if (await integrationOff($)) return null;
357 let dir = null;
358 try { dir = await $.store.get(`${STORE_DIR}:${$.plugin.root}`); } catch { dir = null; }
359 if (typeof dir !== "string" || !dir) return null;
360 try { if (!(await $.fs.exists(`${dir}/logs`))) return null; } catch { return null; }
361 let sessionId = "", cwd = "";
362 try { sessionId = await $.session.id(); } catch { /* none */ }
363 try { cwd = base.cwd || (await $.session.cwd()); } catch { /* none */ }
364 const transcriptPath = base.transcript_path || transcriptPathFor(await configDir($), cwd, sessionId);
365 const stdin = expansionInput({ sessionId, transcriptPath, cwd, permissionMode: base.permission_mode, args: e.args });
366 let r;
367 try {
368 r = await $.process.run(["/bin/bash", `${$.plugin.root}/scripts/toggle.sh`], {
369 stdin, timeoutMs: 30000,
370 env: { ...optionEnv(options), CLAUDE_PLUGIN_ROOT: $.plugin.root, CLAUDE_PLUGIN_DATA: dir },
371 });
372 } catch {
373 return null; // could not start: nothing ran, the classic hook may
374 }
375 const text = toggleText(r.stdout);
376 if (link) void pushContext($);
377 else void discover($, "talk");
378 return { text: text || "voice could not be changed; see logs/toggle.log in the plugin data dir." };
379}
380
381export function register(on, opts) {
382 options = opts || {};
383 instance = newInstance();
384
385 on("session.start", async ($, e, next) => {
386 const r = await next(e);
387 void discover($, "start");
388 return r;
389 });
390
391 on("command.run", async ($, e, next) => {
392 if (!isTalkCommand(e.command)) return next(e);
393 const r = await talk($, e);
394 return r || next(e);
395 });
396
397 // /talk ran toggle.sh in the classic expansion (a settings hook, below the
398 // modules in the chain): once it returned, voice may be on for this session.
399 on("classic.UserPromptExpansion", async ($, e, next) => {
400 const r = await next(e);
401 if (!link) void discover($, "talk");
402 return r;
403 });
404
405 on("classic.UserPromptSubmit", async ($, e, next) => {
406 if (!link) {
407 void discover($, "prompt");
408 return next(e);
409 }
410 if (!e.agent_id && typeof e.prompt_id === "string") promptId = e.prompt_id;
411 forward("UserPromptSubmit", e);
412 const r = await next(e);
413 // hook.sh's framing, for a voice message submitted as a prompt: the same
414 // marker rule, the same text (scripts/voice-context.txt).
415 if (link && link.context && typeof e.prompt === "string" && e.prompt.startsWith(link.marker)) {
416 return { ...(r || {}), additionalContext: [...((r && r.additionalContext) || []), link.context] };
417 }
418 return r;
419 });
420
421 on("classic.PreToolUse", async ($, e, next) => {
422 if (link) push({ kind: "classic", event: "PreToolUse", body: forwardBody("PreToolUse", adaptPreToolUse(e, base, promptId)) });
423 return next(e);
424 });
425
426 on("classic.PermissionRequest", async ($, e, next) => { forward("PermissionRequest", e); return next(e); });
427 on("classic.MessageDisplay", async ($, e, next) => { forward("MessageDisplay", e); return next(e); });
428 on("classic.Notification", async ($, e, next) => { forward("Notification", e); return next(e); });
429 on("classic.Elicitation", async ($, e, next) => { forward("Elicitation", e); return next(e); });
430 on("classic.SubagentStop", async ($, e, next) => { forward("SubagentStop", e); return next(e); });
431 on("classic.TaskCompleted", async ($, e, next) => { forward("TaskCompleted", e); return next(e); });
432 on("classic.TeammateIdle", async ($, e, next) => { forward("TeammateIdle", e); return next(e); });
433 on("classic.PostToolUseFailure", async ($, e, next) => { forward("PostToolUseFailure", e); return next(e); });
434 on("classic.PostToolUse", async ($, e, next) => { forward("PostToolUse", e); return next(e); });
435 on("classic.PermissionDenied", async ($, e, next) => { forward("PermissionDenied", e); return next(e); });
436 on("classic.StopFailure", async ($, e, next) => { forward("StopFailure", e); return next(e); });
437
438 on("classic.Stop", async ($, e, next) => {
439 if (link && !e.agent_id) {
440 // Appends the model never read, reported before the Stop that would
441 // otherwise count them as answered.
442 for (const id of del.missedAtStop()) receipt(id, "requeued");
443 }
444 forward("Stop", e);
445 return next(e);
446 });
447
448 on("classic.SessionEnd", async ($, e, next) => {
449 forward("SessionEnd", e);
450 if (link && up.size) {
451 // Session hooks get 1.5 s in all: one direct POST, no batching wait.
452 try { await $.http.fetch(`${link.base}/mod/events`, { method: "POST", headers: headers(true), body: JSON.stringify({ instance, events: up.batch(200) }) }); } catch { /* the daemon's liveness check covers it */ }
453 }
454 return next(e);
455 });
456
457 // A model request follows each batch of tool results: an append that
458 // resolved before this point is in the next request. (Not turn.step: a
459 // generator hook would take every streamed chunk of every session through
460 // the mods worker, voice on or off.)
461 on("classic.PostToolBatch", async ($, e, next) => {
462 if (link && !e.agent_id) del.stepStart();
463 return next(e);
464 });
465
466 on("turn.start", async ($, e, next) => {
467 if (link) {
468 del.turnStart(e.turnId);
469 push({ kind: "turn", phase: "start", turn_id: e.turnId });
470 }
471 return next(e);
472 });
473
474 on("turn.complete", async ($, e, next) => {
475 const r = await next(e);
476 if (link && !e.agentId) {
477 del.turnComplete();
478 push({ kind: "turn", phase: "complete", turn_id: e.turnId, reason: e.reason, ms: e.durationMs });
479 void pump($);
480 void pushContext($);
481 }
482 return r;
483 });
484
485 // Who started each subagent (phase 5): only the main thread's own Agent
486 // calls are the user's; the daemon ignores the helpers.
487 on("agent.spawn", async ($, e, next) => {
488 const r = await next(e);
489 if (link && r && typeof r.agentId === "string") {
490 push({ kind: "agent", agent_id: r.agentId, parent_agent_id: e.parentAgentId || null, origin: next.origin && next.origin.plugin ? next.origin.plugin : null, background: !!e.background, tool_use_id: e.tool_use_id, description: String(e.description || "").slice(0, 120) });
491 }
492 return r;
493 });
494
495 // Approvals (phase 5): the engine's verdict by tool_use_id. Sotto's own tools
496 // only change its voice settings: allowed without a prompt.
497 on("tool.check", async ($, e, next) => {
498 if (typeof e.tool === "string" && e.tool.startsWith(TOOL_PREFIX)) return { decision: "allow", reason: "Sotto's own voice settings" };
499 const r = await next(e);
500 if (link && r && r.decision === "ask" && typeof e.tool_use_id === "string") {
501 const id = e.tool_use_id;
502 asks.add(id);
503 push({ kind: "approval", phase: "ask", tool_use_id: id, tool: e.tool });
504 // A line under the dialog once it is open. Voice answers stay off.
505 $.clock.after(300, () => {
506 if (!asks.has(id)) return;
507 try { $.ui.notice(id, "sotto: the voice is telling you about this; answer it here"); } catch { /* the dialog closed */ }
508 });
509 }
510 return r;
511 });
512
513 on("tool.call", async ($, e, next) => {
514 if (typeof e.tool === "string" && e.tool.startsWith(TOOL_PREFIX)) return await answerTool($, e);
515 if (!link) return next(e);
516 let r;
517 try {
518 r = await next(e);
519 } catch (err) {
520 if (asks.delete(e.tool_use_id)) push({ kind: "approval", phase: "resolved", tool_use_id: e.tool_use_id, outcome: "error", ...(e.agentId ? { agent_id: e.agentId } : {}) });
521 throw err;
522 }
523 if (asks.delete(e.tool_use_id)) push({ kind: "approval", phase: "resolved", tool_use_id: e.tool_use_id, outcome: r && r.deny ? "denied" : "done", ...(e.agentId ? { agent_id: e.agentId } : {}) });
524 return r;
525 });
526
527 // The band above the prompt (phase 6): "sotto · <phase> · <persona> · <voice>"
528 // and the voice's last words, fitted to the band's width; a survey keeps the band.
529 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
530 if (!link || !ui || (e.props && e.props.hasSurvey)) return next(e);
531 const rows = bandRows(ui, e.props && e.props.bodyColumns);
532 if (!rows.length) return next(e);
533 const { Box, Text } = $.ui.resolve(e);
534 const seg = (g, k) => Text({ key: k, children: g.text, ...(g.bold ? { bold: true } : {}), ...(g.dim ? { dimColor: true } : {}), ...(g.warn ? { color: "warning" } : {}) });
535 return Box({ flexDirection: "column", children: rows.map((row, i) => Text({ key: `l${i}`, wrap: "truncate-end", children: row.map((g, j) => seg(g, `s${j}`)) })) });
536 });
537
538 void FORWARD;
539}
540hooks/modcore.mjs 258 lines1// Pure logic of Sotto's Claude Code mod (SPEC §6.21): no `$`, no I/O, so it is
2// unit-tested with node:test (test/hooks/modcore.test.js) and imported by
3// hooks/sotto-mod.mjs, the hooks module the CLI loads.
4
5/** The classic hook events the mod forwards: hooks.json's list for hook.sh. */
6export const FORWARDED = [
7 "UserPromptSubmit", "PreToolUse", "PermissionRequest", "MessageDisplay", "Notification", "Elicitation",
8 "SubagentStop", "TaskCompleted", "TeammateIdle", "PostToolUseFailure", "PostToolUse", "PermissionDenied",
9 "Stop", "StopFailure", "SessionEnd",
10];
11
12const MAX_STRING = 16 * 1024;
13
14/** A copy of a hook body with long strings cut (a Write's content, a tool's output). */
15export function trimBody(v, depth = 0) {
16 if (typeof v === "string") return v.length > MAX_STRING ? v.slice(0, MAX_STRING) : v;
17 if (!v || typeof v !== "object" || depth > 3) return v;
18 if (Array.isArray(v)) return v.slice(0, 50).map((x) => trimBody(x, depth + 1));
19 const out = {};
20 for (const [k, x] of Object.entries(v)) out[k] = trimBody(x, depth + 1);
21 return out;
22}
23
24/**
25 * classic.PreToolUse hands a mod a ToolCallEnvelope ({tool, tool_use_id,
26 * ...arguments, agentId?}), not the hook's stdin (the CLI 2.1.287 types say so
27 * and a probe saw it). The daemon reads the stdin shape, so rebuild it: the
28 * base fields come from the latest other classic event of the session.
29 */
30export function adaptPreToolUse(e, base = {}, promptId = null) {
31 const { tool, tool_use_id, agentId, ...args } = e || {};
32 const body = {
33 session_id: base.session_id, transcript_path: base.transcript_path, cwd: base.cwd, permission_mode: base.permission_mode,
34 hook_event_name: "PreToolUse", tool_name: tool, tool_input: args, tool_use_id,
35 };
36 if (typeof agentId === "string" && agentId) body.agent_id = agentId;
37 else if (promptId) body.prompt_id = promptId;
38 for (const k of Object.keys(body)) if (body[k] === undefined) delete body[k];
39 return body;
40}
41
42/** The base fields a classic event carries (all but PreToolUse). */
43export function baseOf(e, prev = {}) {
44 if (!e || typeof e !== "object") return prev;
45 const pick = (k) => (typeof e[k] === "string" && e[k] ? e[k] : prev[k]);
46 return { session_id: pick("session_id"), transcript_path: pick("transcript_path"), cwd: pick("cwd"), permission_mode: pick("permission_mode") };
47}
48
49/** The body forwarded for a classic event: PostToolUse without its (unused, maybe huge) tool_response. */
50export function forwardBody(name, e) {
51 if (name === "PostToolUse" && e && typeof e === "object") {
52 const { tool_response, ...rest } = e;
53 return trimBody(rest);
54 }
55 return trimBody(e);
56}
57
58/** "[sotto voice <nonce>]", the marker hook.sh and the daemon use (config.js voiceMarker). */
59export function markerFor(nonce) {
60 return typeof nonce === "string" && /^[0-9a-f]+$/.test(nonce) ? `[sotto voice ${nonce}]` : "[sotto voice]";
61}
62
63/** D/active: "<owner>\t<port>\t<key>[\t<nonce>]". → {owner, port, key, nonce} | null */
64export function parseActive(text) {
65 if (typeof text !== "string") return null;
66 const [owner = "", port = "", key = "", nonce = ""] = text.split("\n")[0].split("\t");
67 if (!owner || !/^\d+$/.test(port) || !key) return null;
68 return { owner, port: Number(port), key, nonce };
69}
70
71/** Does an active file's owner name this session (classic, or already linked to its mod)? */
72export function ownsSession(owner, socket) {
73 return !!socket && (owner === socket || owner === `mod:${socket}`);
74}
75
76/** Bounded set of handled msg_ids: at-least-once downlink, at-most-once delivery. */
77export class Seen {
78 constructor(cap = 256) { this.cap = cap; this.set = new Set(); }
79 has(id) { return this.set.has(id); }
80 add(id) {
81 this.set.add(id);
82 while (this.set.size > this.cap) this.set.delete(this.set.values().next().value);
83 }
84}
85
86/** The ordered uplink: seq-numbered events, kept until the daemon acks them. */
87export class Uplink {
88 constructor(max = 2000) { this.max = max; this.seq = 0; this.queue = []; this.dropped = 0; }
89 push(ev) {
90 const e = { ...ev, seq: ++this.seq };
91 this.queue.push(e);
92 while (this.queue.length > this.max) { this.queue.shift(); this.dropped++; }
93 return e.seq;
94 }
95 batch(n = 50) { return this.queue.slice(0, n); }
96 ack(seq) { if (Number.isFinite(seq)) this.queue = this.queue.filter((e) => e.seq > seq); }
97 get size() { return this.queue.length; }
98 clear() { this.queue = []; }
99}
100
101/**
102 * Where a voice message goes (SPEC §6.21): an idle session gets it as a
103 * prompt of its own ($.prompt.submit), a working one now ($.session.append,
104 * read at the model's next request), or at the end of the turn ("later",
105 * a mirror; "submit_only", a requeue nudge). One submit at a time: two
106 * submitted while a turn runs are merged and one's promise never resolves
107 * (probed on CLI 2.1.287).
108 */
109export class Delivery {
110 constructor({ now = () => Date.now() } = {}) {
111 this.now = now;
112 this.busy = false;
113 this.turnId = null;
114 this.promptId = null;
115 this.stepAt = 0; // when the main thread's latest model request started
116 this.appended = []; // {msg_id, at} appended during this turn
117 this.queue = []; // items waiting for an idle session, in order
118 }
119
120 /** → "submit" | "append" | "queue" */
121 route(item) {
122 if (!this.busy) return "submit";
123 if (item.priority === "later" || item.submit_only) return "queue";
124 return "append";
125 }
126
127 turnStart(turnId) { this.busy = true; this.turnId = turnId || null; this.stepAt = this.now(); this.appended = []; }
128 stepStart() { this.stepAt = this.now(); }
129 noteAppend(msgId) { this.appended.push({ msg_id: msgId, at: this.now() }); }
130
131 /**
132 * At the main thread's Stop: appends that resolved after the turn's last
133 * model request started were never read by the model (probed: it does not
134 * run another step for them). Their msg_ids, for a "requeued" receipt each.
135 */
136 missedAtStop() {
137 const missed = this.appended.filter((a) => a.at >= this.stepAt).map((a) => a.msg_id);
138 this.appended = [];
139 return missed;
140 }
141
142 turnComplete() { this.busy = false; this.turnId = null; this.appended = []; }
143
144 enqueue(item) { this.queue.push(item); }
145 nextQueued() { return this.busy ? null : this.queue.shift() || null; }
146}
147
148/** The text appended mid-turn: the message, then the voice framing hook.sh adds as context to a prompt. */
149export function appendText(content, context) {
150 return context ? `${content}\n\n(${context})` : content;
151}
152
153// ---- phase 4: /talk from the mod, the conversation for the seed -----------------
154
155/**
156 * CLAUDE_PLUGIN_OPTION_* for toggle.sh run by the mod: the userConfig values
157 * register() got (defaults filled in). idle_seconds' default is left out when
158 * the legacy idle_minutes was set, as an unset option would be (SPEC §4.2).
159 */
160export function optionEnv(options = {}) {
161 const env = {};
162 for (const [k, v] of Object.entries(options || {})) {
163 if (!/^[a-z_]+$/.test(k) || v === undefined || v === null || typeof v === "object") continue;
164 env[`CLAUDE_PLUGIN_OPTION_${k.toUpperCase()}`] = String(v);
165 }
166 if (Number(options?.idle_seconds) === 60 && options?.idle_minutes !== undefined && Number(options.idle_minutes) !== 5) delete env.CLAUDE_PLUGIN_OPTION_IDLE_SECONDS;
167 return env;
168}
169
170/** Is this command.run the plugin's /talk? */
171export function isTalkCommand(name) {
172 return name === "sotto:talk" || name === "talk";
173}
174
175/** The transcript file Claude Code keeps for a session: <config>/projects/<cwd, non-alphanumerics as "-">/<id>.jsonl. */
176export function transcriptPathFor(configDir, cwd, sessionId) {
177 if (!configDir || !cwd || !sessionId) return "";
178 return `${configDir}/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, "-")}/${sessionId}.jsonl`;
179}
180
181/** UserPromptExpansion stdin for toggle.sh, as Claude Code would hand it. */
182export function expansionInput({ sessionId, transcriptPath, cwd, permissionMode, args }) {
183 const a = typeof args === "string" ? args : "";
184 return JSON.stringify({
185 session_id: sessionId || "", transcript_path: transcriptPath || "", cwd: cwd || "", permission_mode: permissionMode || "default",
186 hook_event_name: "UserPromptExpansion", expansion_type: "slash_command", command_name: "sotto:talk", command_args: a,
187 prompt: `/sotto:talk${a ? ` ${a}` : ""}`,
188 });
189}
190
191/** toggle.sh's one line ({"continue":false,"stopReason":…}) as command text, without the "sotto: " Claude Code adds itself. */
192export function toggleText(stdout) {
193 const line = String(stdout || "").trim().split("\n").pop() || "";
194 let j = null;
195 try { j = JSON.parse(line); } catch { return null; }
196 if (!j || typeof j.stopReason !== "string") return null;
197 return j.stopReason.replace(/^sotto:\s*/, "");
198}
199
200/** $.session.messages() rows → [{role, text}] for the daemon's seed (newest last). */
201export function contextOf(rows, max = 40) {
202 const out = [];
203 for (const r of Array.isArray(rows) ? rows.slice(-max * 2) : []) {
204 if (!r || (r.role !== "user" && r.role !== "assistant") || typeof r.text !== "string" || !r.text.trim()) continue;
205 out.push({ role: r.role, text: r.text.length > 4000 ? r.text.slice(0, 4000) : r.text });
206 }
207 return out.slice(-max);
208}
209
210// ---- phase 6: what the mod draws -------------------------------------------------
211
212/** The phase words of the band. */
213export function phaseOf(s) {
214 if (!s) return "";
215 if (s.approval) return "approval needed";
216 if (s.state === "live") return s.busy ? "Claude is working" : "listening";
217 if (s.state === "sleeping") return s.busy ? "Claude is working, voice asleep" : "asleep, talk to wake it";
218 return s.state || "";
219}
220
221/** Text on one line, cut to `cols` cells (a code point per cell is close enough here). */
222export function fit(text, cols) {
223 const t = String(text || "").replace(/\s+/g, " ").trim();
224 if (!(cols > 0)) return "";
225 const chars = [...t];
226 return chars.length <= cols ? t : chars.slice(0, Math.max(0, cols - 1)).join("") + "…";
227}
228
229const PROBLEM_WORDS = { held: "held", cant_hear: "can't hear you", error: "error" };
230
231/**
232 * The band above the prompt, the one place Sotto shows itself in the
233 * terminal: a header row "sotto · <phase> · <persona> · <voice>" and a
234 * caption row (the voice's last words in quotes, else the latest activity;
235 * a problem's own words when there is one). Each row is a list of segments
236 * `{ text, bold?, dim?, warn? }` fitted to `cols`: on a narrow band the voice
237 * and then the persona go first, then the phase is cut. Nothing while voice
238 * is off. A warning style only for a real problem (held, can't hear, error).
239 */
240export function bandRows(s, cols) {
241 if (!s || !s.state || s.state === "off") return [];
242 const w = Math.max(10, Number(cols) || 80);
243 const p = s.problem && PROBLEM_WORDS[s.problem.kind] ? s.problem : null;
244 const phase = p ? PROBLEM_WORDS[p.kind] : `${phaseOf(s)}${s.approval ? `: ${s.approval}` : ""}`;
245 const extras = [s.persona, s.voice].filter(Boolean).map((t) => ({ text: ` · ${t}`, dim: true }));
246 const name = { text: "sotto", bold: true };
247 const width = (segs) => segs.reduce((n, g) => n + [...g.text].length, 0);
248 while (extras.length && width([name, { text: ` · ${phase}` }, ...extras]) > w) extras.pop();
249 const head = [name, { text: ` · ${fit(phase, w - 8)}`, ...(p ? { warn: true } : {}) }, ...extras];
250 const body = p ? p.text : s.said ? `"${s.said}"` : s.activity || "";
251 return body ? [head, [{ text: fit(body, w), ...(p ? { warn: true } : { dim: true }) }]] : [head];
252}
253
254/** The band's rows as plain text (tests, logs). */
255export function bandLines(s, cols) {
256 return bandRows(s, cols).map((row) => row.map((g) => g.text).join(""));
257}
258