SLOPSHOPPER

Sotto

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

newbandguardtoolprocessnetwork
v0.6.3MITupdated 2026-10-02chadboyda/sotto
A shopper browsing a rack in a slop shop
README

Sotto

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)
  • Full duplex. Interrupt the voice at any time. Interrupting it does not cancel Claude's work; press Esc in the terminal for that.
  • Zero cost when off. The plugin's hooks fire in every session, but they exit in about 5 ms without reading their input unless this session owns voice. You see no notices and no errors.
  • No model turn to toggle. /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.

Requirements

  • macOS (tested on macOS 26, Apple silicon). The daemon and page are portable, but the hooks, window handling and desktop app are only tested on macOS.
  • Claude Code 2.1.281 or later (it needs cross-session messaging and the 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.
  • Node.js 22.6 or later on 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.
  • A voice window: the native Sotto desktop app (downloaded signed and notarized on first use; no Xcode needed), or Google Chrome. Chrome is recommended either way as the fallback: its echo cancellation lets you use laptop speakers without the voice hearing itself. With neither, the page opens in your default browser.
  • An OpenAI API key with access to gpt-live-1. Voice is billed by OpenAI to that key (see Cost).

Install

From the marketplace

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.

  1. Create a key at platform.openai.com/api-keys. Its project must be allowed to use 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.
  2. Paste it into the window and press Save. Sotto checks it with OpenAI first (it must be accepted and list 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.
  3. A good key is saved in your macOS Keychain (login keychain, item "Sotto OpenAI API key": service 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:

WhereHow
1. The environmentexport OPENAI_API_KEY=sk-... before starting Claude Code
2. A .env fileOPENAI_API_KEY=sk-... in <plugin dir>/.env, <plugin data dir>/.env or ~/.sotto/.env (chmod 600 it)
3. The macOS Keychainwhat 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 settingsthe 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.

From a clone (for development)

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.

Usage

CommandEffect
/talkToggle voice for this session
/talk onTurn voice on here (or move it here from another session)
/talk offTurn voice off, from any session
/talk statusState, owner project, minutes and cost today, voice, persona, policy, last error
/talk restartRestart the voice daemon on the latest code at the next pause (see "Updates" below)
/talk quiet / milestones / walkthroughChange how much the voice narrates (see below)
/talk voiceList 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 personaList 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 appUse 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 keyWhich 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:

  1. The voice window opens and connects: the Sotto desktop app's floating panel, or a small Chrome window (see Desktop app). The first time, macOS or Chrome asks for the microphone. The voice greets you.
  2. Just talk. Chit-chat stays with the voice. Anything about the code ("what's failing in the tests?", "rename that function") goes to Claude as [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.
  3. When Claude finishes, the voice summarizes the answer. Claude's reply is written voice-first: a short spoken summary, then the details, which stay on screen in the terminal.
  4. If Claude needs a permission, the voice tells you to approve it in the terminal. It cannot approve anything itself.

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

Desktop app

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

  • How it gets installed. Claude Code installs a plugin as a plain copy of this git repository, and we don't commit compiled apps to git. So the plugin fetches the app itself, the first time you use it:
  • The first /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.
  • Or run /talk app. It installs the app if it is missing (or retries a failed install), opens the voice in it, and saves window = app.
  • What gets checked. The download is installed only if its sha256 matches, it was built from this plugin's app-native/ sources, it is code-signed by team 6M6D2W72ZB and Gatekeeper accepts its notarization.
  • When the plugin builds it instead. If you edited 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).
  • Where it goes. The app lives in the plugin data directory (app/Sotto.app) and is replaced when the plugin's app-native/ sources change. Every step is logged to logs/app-build.log.
  • If it fails. /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.
  • First app launch: macOS asks "Sotto would like to access the microphone". Click Allow once. If you clicked Don't Allow, turn it on in System Settings → Privacy & Security → Microphone → Sotto.
  • Menu-bar icon: a short string with Claude's moon phase (idle, working, done), gold when Claude needs you, and a warning symbol on errors. Its menu has Show/Hide Panel, Compact Panel (a small strip with the string, the headline and the caption line), Mute, Voice Off, Open Logs, and Quit.
  • Hotkeys, anywhere: ⌥⌘M mute or unmute, ⌥⌘T show or hide the panel. To change them: 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.
  • Hiding the panel does not stop voice (the icon still shows it). Voice Off in the menu, End voice in the panel, or /talk off ends it. When voice goes off the app quits, unless you tick "Stay in Menu Bar When Voice Is Off".
  • AirPods and other headphones: Sotto uses the microphone you choose in Settings, or your Mac's default input (Automatic), whatever it is: wearing a headset is a reason to talk into it. A Bluetooth mic switches the headphones to call quality while it is open, as in FaceTime or Zoom. With Automatic, Sotto follows macOS when the default changes (you take the AirPods off, plug in a mic), live or asleep. Headphones need no echo cancellation. On speakers the app uses macOS's voice processing, which cancels the echo but lowers other apps' audio by about 15 dB while the mic is open (with voice wake, for as long as voice is on). The app evens out the mic's level for the voice (quiet headset mics included) and learns how loud you speak on each mic, so voice wake and hearing work at your normal volume.
  • Choose explicitly with /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:

  • type /talk voice cedar (no model turn);
  • ask out loud ("can you switch to the cedar voice?"): the voice hands that to Claude, which runs 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 Voice picker in the voice window's settings (the gear).

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.

VoiceSoundsPresentationAccent
alloySmooth, clear, evenandrogynousAmerican
ashClear, crisp, steadymasculineAmerican
balladWarm, easygoing, lightly breathymasculineAmerican
beaconClean, crisp, articulatemasculineFilipino
bossaSoft, breathy, gentlefeminineBrazilian
cedarRelaxed, textured, casualmasculineAmerican
cinderDeep, calm, groundedmasculineSouthern US
coralBright, lively, upbeatfeminineAmerican
deltaBright, crisp, friendlyfeminineSouthern US
echoSmooth, warm, lowmasculineAmerican
gleamCheerful, smooth, warmfeminineNorth American
marinBright, clear, polishedfeminineAmerican
meridianDeep, clear, easygoingmasculineNorth American
quartzBright, airy, buoyantfeminineAustralian
rippleSmooth, dry, relaxedmasculineAustralian
sageBright, clear, measuredfeminineAmerican
shimmerCrisp, smooth, calmlower, androgynousAmerican
stoneDeep, relaxed, groundedmasculineIrish
tempoEasygoing, smooth, lowmasculineBrazilian
verseClear, relaxed, a little gravelmasculineAmerican
vesperDry, low-key, groundedmasculineBritish
willowBright, crisp, warmfeminineIrish

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.

Decisions reach Claude, even ones the voice answered

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.

Speakers, echo and talking over the voice

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:

  1. Echo cancellation in the browser (Chrome) or in macOS (the desktop app on speakers), which knows exactly what the voice plays on which speaker. This does nearly all the work.
  2. An echo filter on what reaches Claude: words that are the voice's own speech heard back (matched in time and wording, allowing for how speech recognition spells things), and Sotto's own fixed sentences from another Sotto in the room, are never sent to Claude as yours. When you talk over the voice, only its words are cut; yours go through.
  3. An echo guard, only when needed. The voice window measures how much of the voice the microphone still hears after echo cancellation. If that stays high (a speaker the echo canceller cannot see, a very loud speaker), a banner says "Echo detected — headphones recommended" and the guard turns on: it lowers the microphone only at moments it holds nothing louder than the echo, and opens instantly when you speak, so you can still talk over the voice and say "mm-hm". The 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.

Speaking policies

PolicyThe voice speaks…
quietOnly 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.
walkthroughAlso narrates Claude's progress as it works, task checklist ticks, teammates going idle, and failed tool steps.

What gets said, per event:

Eventquietmilestoneswalkthrough
Answer to your voice requestspokenspokenspoken, longer
Claude asks you a question (AskUserQuestion): "Claude's asking: which layout? Options: A, B, or C. Answer in the terminal."spokenspokenspoken
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 answeredspokenspokenspoken
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)spokenspokenspoken
MCP server needs input (Elicitation), a background session needs input, usage limit reset and waiting for Enterspokenspokenspoken
Claude Code hit an API error (StopFailure)spokenspokenspoken
Background work you asked for by voice finished (its task-notification turn)spokenspokenspoken
Turn you typed finishedsilent noteone sentencesummary
Background work from a typed turn finishedsilent noteone sentencetwo sentences
Subagent finished (SubagentStop), batched over 3 ssilent notespokenspoken
Claude is idle and waiting (idle_prompt), once per idle period, only if its result wasn't already spokensilent notespokenspoken
Intermediate progress
Source 2 files
hooks/sotto-mod.mjs 540 lines
1// 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}
540
hooks/modcore.mjs 258 lines
1// 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