totopo 3.8.0 → 3.9.0-rc-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -236,13 +236,23 @@ To clear memory: `npx totopo` → **Manage totopo > Clear agent memory**.
236
236
  └── shadows/ # container-local shadow path storage
237
237
  ```
238
238
 
239
+ ## Voice Mode (microphone)
240
+
241
+ Claude Code's `/voice` records from a mic via SoX, but a container has none (on Docker Desktop the Linux VM has no device passthrough). totopo bridges your host mic in over a local PulseAudio server — **opt-in per workspace** from **Manage Workspace → Voice / audio**.
242
+
243
+ **macOS (automated):** in that menu, **Enable wiring**, then **Install pulseaudio → Start host server → Test microphone** (approve the mic prompt for your terminal under **System Settings → Privacy & Security → Microphone**). Open a session and run `/voice`. The main menu reminds you while the server runs — stop it there when done.
244
+
245
+ **Linux / Windows (manual):** automation is macOS-only. **Enable wiring**, then run your own PulseAudio server reachable at TCP `4713` (load `module-native-protocol-tcp`). It must accept totopo's cookie at `~/.totopo/pulse-cookie` (mounted read-only into the container), or load the module with `auth-anonymous=1`. On Windows the source is typically the WSLg PulseAudio server.
246
+
247
+ > **Security:** while running, the server exposes your mic on a local TCP port, gated by an `auth-ip-acl` (private networks only) and — the real gate — a **dedicated, rotating cookie**: totopo-owned (not your general PulseAudio credential), mounted read-only, regenerated on every server start, so a leaked cookie dies on the next restart. Still, run the server only while you need voice and stop it after. A deliberate widening of totopo's boundary — see [Threat Model](#threat-model).
248
+
239
249
  ## Troubleshooting
240
250
 
241
251
  **Move or rename the workspace directory** — re-run `npx totopo` in the new location. totopo detects the path mismatch and guides you through realigning the workspace cache.
242
252
 
243
253
  **Single machine** — `~/.totopo/` is local. Switching machines requires re-running setup in each workspace.
244
254
 
245
- **Audio** — `sox` is included (required by Claude Code for voice mode), but audio passthrough depends on your OS. macOS, Linux, and Windows each require different device configuration.
255
+ **Voice mode / audio** — `/voice` needs a microphone, which a container does not have by default. Enable it under **Manage Workspace Voice / audio**; see [Voice Mode](#voice-mode-microphone).
246
256
 
247
257
  **Shift+Enter not working in VS Code terminal** — add this to your VS Code keybindings (`Cmd+Shift+P` → "Open Keyboard Shortcuts (JSON)"):
248
258
 
@@ -269,6 +279,8 @@ Totopo makes everyday agent mistakes safer. It is not built to stop a determined
269
279
  - Container escapes. Totopo uses a non-root user and `no-new-privileges`, but no capability drops or seccomp profiles. For stronger isolation, use a microVM sandbox.
270
280
  - Edits to your working tree. The workspace is bind-mounted, so agent changes land on your real files. Commit often.
271
281
 
282
+ **Voice mode widens this boundary** — enabling the mic bridge runs a host PulseAudio server that exposes your microphone over a local TCP port (cookie- and ACL-gated) and that totopo never stops for you. Opt in only while dictating; see [Voice Mode](#voice-mode-microphone).
283
+
272
284
  ## Disclaimer
273
285
 
274
286
  MIT licensed and fully open source. Issues welcome — no promises on response time. Use at your own risk.
package/bin/totopo.js CHANGED
@@ -15,6 +15,7 @@ import { run as globalMenu } from "../dist/commands/global.js";
15
15
  import { run as menu } from "../dist/commands/menu.js";
16
16
  import { run as onboard } from "../dist/commands/onboard.js";
17
17
  import { resetImage, stop, run as workspaceMenu } from "../dist/commands/workspace.js";
18
+ import { isAudioServerRunning } from "../dist/lib/audio-host.js";
18
19
  import { GITHUB_README_URL, repairTotopoYaml } from "../dist/lib/totopo-yaml.js";
19
20
  import { deriveContainerName, findTotopoYamlDir, listWorkspaceIds, resolveWorkspace } from "../dist/lib/workspace-identity.js";
20
21
 
@@ -152,7 +153,10 @@ while (showMenu) {
152
153
  const activeCount = activeNames.length;
153
154
  const workspaceRunning = activeNames.some((n) => n === containerName);
154
155
 
155
- const action = await menu({ ctx: workspace, activeCount, workspaceRunning, version });
156
+ // Host audio server is global and totopo never stops it on its own; surface it in the status box while up.
157
+ const audioServerRunning = isAudioServerRunning();
158
+
159
+ const action = await menu({ ctx: workspace, activeCount, workspaceRunning, audioServerRunning, version });
156
160
 
157
161
  switch (action) {
158
162
  case "dev":
@@ -8,13 +8,14 @@ import { existsSync } from "node:fs";
8
8
  import { join, relative } from "node:path";
9
9
  import { cancel, confirm, isCancel, log, outro, select } from "@clack/prompts";
10
10
  import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext } from "../lib/agent-context.js";
11
- import { CONTAINER_STARTUP, CONTAINER_WORKSPACE, GIT_MODE, LABEL_GIT_MODE, LABEL_MANAGED, LABEL_PROFILE, LABEL_RUNTIME_ENV, LABEL_SHADOWS, PROFILE, RUNTIME_ENV, } from "../lib/constants.js";
11
+ import { ensureCookieFile } from "../lib/audio-host.js";
12
+ import { AUDIO_COOKIE_CONTAINER_PATH, AUDIO_PULSE_SERVER, AUDIODRIVER_VALUE, CONTAINER_STARTUP, CONTAINER_WORKSPACE, GIT_MODE, LABEL_AUDIO, LABEL_GIT_MODE, LABEL_MANAGED, LABEL_PROFILE, LABEL_RUNTIME_ENV, LABEL_SHADOWS, PROFILE, RUNTIME_ENV, } from "../lib/constants.js";
12
13
  import { buildDockerfile, buildImageWithTempfile, computeBuildHash } from "../lib/dockerfile-builder.js";
13
14
  import { isImageStale } from "../lib/migrate-to-latest.js";
14
15
  import { buildPnpmStoreMountArgs } from "../lib/pnpm-store.js";
15
16
  import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
16
17
  import { readTotopoYaml } from "../lib/totopo-yaml.js";
17
- import { readActiveProfile, readGitMode, writeActiveProfile } from "../lib/workspace-identity.js";
18
+ import { readActiveProfile, readAudio, readGitMode, writeActiveProfile } from "../lib/workspace-identity.js";
18
19
  // --- Prompt: working directory selection -------------------------------------------------------------------------------------------------
19
20
  async function promptWorkdir(workspaceDir, cwd) {
20
21
  if (cwd === workspaceDir)
@@ -65,18 +66,19 @@ async function selectProfile(ctx, profiles) {
65
66
  }
66
67
  // Returns null when the container does not exist (docker inspect exits non-zero).
67
68
  function inspectContainer(containerName) {
68
- const fmt = `{{.State.Status}}|{{index .Config.Labels "${LABEL_SHADOWS}"}}|{{index .Config.Labels "${LABEL_PROFILE}"}}|{{index .Config.Labels "${LABEL_RUNTIME_ENV}"}}|{{index .Config.Labels "${LABEL_GIT_MODE}"}}`;
69
+ const fmt = `{{.State.Status}}|{{index .Config.Labels "${LABEL_SHADOWS}"}}|{{index .Config.Labels "${LABEL_PROFILE}"}}|{{index .Config.Labels "${LABEL_RUNTIME_ENV}"}}|{{index .Config.Labels "${LABEL_GIT_MODE}"}}|{{index .Config.Labels "${LABEL_AUDIO}"}}`;
69
70
  const result = spawnSync("docker", ["inspect", "--format", fmt, containerName], { encoding: "utf8", stdio: "pipe" });
70
71
  if (result.status !== 0)
71
72
  return null;
72
73
  const clean = (s) => (s === "<no value>" ? "" : s);
73
- const [status = "", shadows = "", profile = "", runtimeEnv = "", gitMode = ""] = result.stdout.trim().split("|");
74
+ const [status = "", shadows = "", profile = "", runtimeEnv = "", gitMode = "", audio = ""] = result.stdout.trim().split("|");
74
75
  return {
75
76
  status,
76
77
  shadowLabel: clean(shadows),
77
78
  profileLabel: clean(profile),
78
79
  runtimeEnvLabel: clean(runtimeEnv),
79
80
  gitModeLabel: clean(gitMode),
81
+ audioLabel: clean(audio),
80
82
  };
81
83
  }
82
84
  // --- Shadow label ------------------------------------------------------------------------------------------------------------------------
@@ -112,7 +114,7 @@ function runStartup(containerName, quiet) {
112
114
  return result.status === 0;
113
115
  }
114
116
  export function startContainer(opts) {
115
- const { containerName, workspaceRoot, cacheDir, templatesDir, activeProfile, profileHook, expandedShadows, envFilePath, hasGit, gitMode, shadowPatterns, workspaceName, noCache, quiet = false, } = opts;
117
+ const { containerName, workspaceRoot, cacheDir, templatesDir, activeProfile, profileHook, expandedShadows, envFilePath, hasGit, gitMode, audio, audioCookiePath, shadowPatterns, workspaceName, noCache, quiet = false, } = opts;
116
118
  const stdio = quiet ? "pipe" : "inherit";
117
119
  // --- Sync shadows and build mount args ------------------------------------------------------------------------------------------------
118
120
  ensureShadowsInSync(cacheDir, expandedShadows, workspaceRoot);
@@ -141,6 +143,8 @@ export function startContainer(opts) {
141
143
  `${LABEL_RUNTIME_ENV}=${runtimeEnvLabel()}`,
142
144
  "--label",
143
145
  `${LABEL_GIT_MODE}=${gitMode}`,
146
+ "--label",
147
+ `${LABEL_AUDIO}=${audio}`,
144
148
  ];
145
149
  // --- Runtime env vars -----------------------------------------------------------------------------------------------------------------
146
150
  const runtimeEnvArgs = [
@@ -150,6 +154,25 @@ export function startContainer(opts) {
150
154
  "-e",
151
155
  `TOTOPO_GIT_MODE=${gitMode}`,
152
156
  ];
157
+ // --- Audio bridge (Claude Code /voice) ------------------------------------------------------------------------------------------------
158
+ // When enabled, point SoX 'rec' at the host PulseAudio server. --add-host makes host.docker.internal
159
+ // resolve on native Linux (it is automatic on Docker Desktop, where the flag is harmless).
160
+ // When the host cookie exists, mount it read-only and set PULSE_COOKIE so the container can
161
+ // authenticate; the server requires this shared secret, so only wired containers can connect.
162
+ const audioCookieArgs = audio && audioCookiePath
163
+ ? ["-e", `PULSE_COOKIE=${AUDIO_COOKIE_CONTAINER_PATH}`, "-v", `${audioCookiePath}:${AUDIO_COOKIE_CONTAINER_PATH}:ro`]
164
+ : [];
165
+ const audioRunArgs = audio
166
+ ? [
167
+ "-e",
168
+ `PULSE_SERVER=${AUDIO_PULSE_SERVER}`,
169
+ "-e",
170
+ `AUDIODRIVER=${AUDIODRIVER_VALUE}`,
171
+ "--add-host",
172
+ "host.docker.internal:host-gateway",
173
+ ...audioCookieArgs,
174
+ ]
175
+ : [];
153
176
  // --- Inspect container state ---------------------------------------------------------------------------------------------------------
154
177
  const info = inspectContainer(containerName);
155
178
  let containerStatus = info?.status ?? null;
@@ -160,7 +183,8 @@ export function startContainer(opts) {
160
183
  const profileChanged = info.profileLabel !== activeProfile;
161
184
  const runtimeEnvChanged = info.runtimeEnvLabel !== runtimeEnvLabel();
162
185
  const gitModeChanged = info.gitModeLabel !== gitMode;
163
- if (shadowChanged || profileChanged || runtimeEnvChanged || gitModeChanged) {
186
+ const audioChanged = info.audioLabel !== String(audio);
187
+ if (shadowChanged || profileChanged || runtimeEnvChanged || gitModeChanged || audioChanged) {
164
188
  stopAndRemoveContainer(containerName);
165
189
  containerStatus = null;
166
190
  if (profileChanged) {
@@ -177,6 +201,10 @@ export function startContainer(opts) {
177
201
  if (!quiet)
178
202
  log.info(`Git mode changed (${info.gitModeLabel || "<unset>"} -> ${gitMode}) — recreating container...`);
179
203
  }
204
+ else if (audioChanged) {
205
+ if (!quiet)
206
+ log.info(`Voice/audio ${audio ? "enabled" : "disabled"} — recreating container...`);
207
+ }
180
208
  else {
181
209
  if (!quiet)
182
210
  log.info("Runtime environment updated — recreating container...");
@@ -207,6 +235,7 @@ export function startContainer(opts) {
207
235
  ...mountArgs,
208
236
  ...envFileArgs,
209
237
  ...runtimeEnvArgs,
238
+ ...audioRunArgs,
210
239
  "--security-opt",
211
240
  "no-new-privileges:true",
212
241
  ...labelArgs,
@@ -287,6 +316,12 @@ export async function run(packageDir, ctx, options) {
287
316
  const hasGit = existsSync(join(workspaceDir, ".git"));
288
317
  // --- Git mode (per-workspace, host-side .lock) ---------------------------------------------------------------------------------------
289
318
  const gitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
319
+ // --- Audio bridge opt-in (per-workspace, host-side .lock) ----------------------------------------------------------------------------
320
+ const audio = readAudio(ctx.workspaceId);
321
+ // Ensure totopo's dedicated host cookie exists so the read-only mount target is always valid; the
322
+ // host server rotates it on each cold start. Creating it here (when absent) avoids any need to
323
+ // recreate the container after the server first starts.
324
+ const audioCookiePath = audio ? ensureCookieFile() : undefined;
290
325
  // --- Start container -----------------------------------------------------------------------------------------------------------------
291
326
  const containerOpts = {
292
327
  containerName,
@@ -299,6 +334,8 @@ export async function run(packageDir, ctx, options) {
299
334
  envFilePath,
300
335
  hasGit,
301
336
  gitMode,
337
+ audio,
338
+ ...(audioCookiePath !== undefined && { audioCookiePath }),
302
339
  shadowPatterns,
303
340
  workspaceName: ctx.workspaceId,
304
341
  ...(options?.noCache !== undefined && { noCache: options.noCache }),
@@ -9,14 +9,18 @@ import { box, cancel, isCancel, select } from "@clack/prompts";
9
9
  import { PROFILE } from "../lib/constants.js";
10
10
  import { readActiveProfile } from "../lib/workspace-identity.js";
11
11
  export async function run(args) {
12
- const { ctx, workspaceRunning, version } = args;
12
+ const { ctx, workspaceRunning, audioServerRunning, version } = args;
13
13
  // --- Read workspace config -----------------------------------------------------------------------------------------------------------
14
14
  const activeProfile = readActiveProfile(ctx.workspaceId) ?? PROFILE.default;
15
15
  const hasGit = existsSync(join(ctx.workspaceRoot, ".git"));
16
16
  // --- Status box ----------------------------------------------------------------------------------------------------------------------
17
17
  const containerStatus = workspaceRunning ? "running" : "stopped";
18
18
  const gitNotice = hasGit ? "" : `\n${styleText("yellow", "●")} no git — agent changes are not tracked`;
19
- box(`workspace: ${ctx.workspaceId}\nprofile: ${activeProfile}\ncontainer: ${containerStatus}${gitNotice}`, ` totopo v${version} `, {
19
+ // totopo never stops the host audio server itself, so remind the user while it is up.
20
+ const audioNotice = audioServerRunning
21
+ ? `\n${styleText("yellow", "●")} audio mic server running (Manage Workspace › Voice / audio)`
22
+ : "";
23
+ box(`workspace: ${ctx.workspaceId}\nprofile: ${activeProfile}\ncontainer: ${containerStatus}${gitNotice}${audioNotice}`, ` totopo v${version} `, {
20
24
  contentAlign: "left",
21
25
  titleAlign: "center",
22
26
  width: "auto",
@@ -4,10 +4,11 @@
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { relative } from "node:path";
6
6
  import { cancel, confirm, isCancel, log, multiselect, note, outro, path, select, text } from "@clack/prompts";
7
- import { GIT_MODE } from "../lib/constants.js";
7
+ import { getStatus, IS_MACOS, installPulse, startServer, stopServer, testMic } from "../lib/audio-host.js";
8
+ import { AUDIO_TCP_PORT, GIT_MODE } from "../lib/constants.js";
8
9
  import { countPatternHits } from "../lib/shadows.js";
9
10
  import { buildDefaultTotopoYaml, readTotopoYaml, writeTotopoYaml } from "../lib/totopo-yaml.js";
10
- import { readGitMode, writeGitMode } from "../lib/workspace-identity.js";
11
+ import { readAudio, readGitMode, writeAudio, writeGitMode } from "../lib/workspace-identity.js";
11
12
  // --- Shadow paths menu -------------------------------------------------------------------------------------------------------------------
12
13
  async function shadowPathsMenu(ctx) {
13
14
  const yaml = readTotopoYaml(ctx.workspaceRoot);
@@ -161,6 +162,68 @@ async function gitModeMenu(ctx) {
161
162
  log.success(`Git mode set to ${choice}.`);
162
163
  await promptStopContainer(ctx);
163
164
  }
165
+ // --- Voice / audio menu ------------------------------------------------------------------------------------------------------------------
166
+ async function audioMenu(ctx) {
167
+ while (true) {
168
+ const wiring = readAudio(ctx.workspaceId);
169
+ const status = getStatus();
170
+ const serverLine = !status.installed ? "not installed" : status.running ? `running on TCP ${AUDIO_TCP_PORT}` : "installed, stopped";
171
+ note(`wiring: ${wiring ? "enabled" : "disabled"} (this workspace)\n` +
172
+ `host server: ${serverLine}` +
173
+ (status.version ? `\nversion: ${status.version}` : ""), "Voice / audio");
174
+ log.message("Claude Code /voice needs a microphone, which the container does not have.\n" +
175
+ "Enable wiring (per-workspace) and run a host PulseAudio server that streams your mic in.\n" +
176
+ "Access needs a dedicated, rotating cookie (and is limited to private networks), but the server\n" +
177
+ "still exposes your mic over a local TCP port — totopo never stops it for you, so stop it here when done.");
178
+ if (!IS_MACOS) {
179
+ log.info("Host server control is automated on macOS only. On Linux/Windows, start a PulseAudio server on the host manually — see the README.");
180
+ }
181
+ const options = [
182
+ { value: "toggle", label: wiring ? "Disable wiring" : "Enable wiring", hint: "PulseAudio env for this workspace's container" },
183
+ ];
184
+ if (IS_MACOS) {
185
+ if (!status.installed)
186
+ options.push({ value: "install", label: "Install pulseaudio", hint: "via Homebrew" });
187
+ if (status.installed && !status.running)
188
+ options.push({ value: "start", label: "Start host server", hint: `TCP ${AUDIO_TCP_PORT}` });
189
+ if (status.running) {
190
+ options.push({ value: "test", label: "Test microphone", hint: "record 3s and check capture" });
191
+ options.push({ value: "stop", label: "Stop host server" });
192
+ }
193
+ }
194
+ options.push({ value: "back", label: "← Back" });
195
+ const action = await select({ message: "Voice / audio:", options });
196
+ if (isCancel(action) || action === "back")
197
+ return;
198
+ if (action === "toggle") {
199
+ const next = !wiring;
200
+ writeAudio(ctx.workspaceId, next);
201
+ log.success(`Voice/audio wiring ${next ? "enabled" : "disabled"} for this workspace.`);
202
+ await promptStopContainer(ctx);
203
+ continue;
204
+ }
205
+ let result;
206
+ if (action === "install") {
207
+ result = installPulse();
208
+ }
209
+ else if (action === "start") {
210
+ result = startServer();
211
+ }
212
+ else if (action === "stop") {
213
+ result = stopServer();
214
+ }
215
+ else {
216
+ log.step("Recording 3 seconds — speak now...");
217
+ result = testMic();
218
+ }
219
+ if (result.ok) {
220
+ log.success(result.message);
221
+ }
222
+ else {
223
+ log.warn(result.message);
224
+ }
225
+ }
226
+ }
164
227
  // --- Prompt to stop container ------------------------------------------------------------------------------------------------------------
165
228
  async function promptStopContainer(ctx) {
166
229
  const containerName = ctx.containerName;
@@ -208,6 +271,7 @@ export async function run(ctx) {
208
271
  const options = [
209
272
  { value: "git-mode", label: "Git mode", hint: `current: ${currentGitMode}` },
210
273
  { value: "shadow-paths", label: "Shadow paths", hint: "manage shadow patterns" },
274
+ { value: "audio", label: "Voice / audio", hint: "Claude Code /voice mic setup" },
211
275
  { value: "rebuild", label: "Rebuild container", hint: "force a fresh image build" },
212
276
  { value: "clean-rebuild", label: "Clean rebuild", hint: "fresh build, no cache" },
213
277
  { value: "reset", label: "Reset config", hint: "restore totopo.yaml to defaults" },
@@ -224,6 +288,9 @@ export async function run(ctx) {
224
288
  case "shadow-paths":
225
289
  await shadowPathsMenu(ctx);
226
290
  break;
291
+ case "audio":
292
+ await audioMenu(ctx);
293
+ break;
227
294
  case "rebuild":
228
295
  return "rebuild";
229
296
  case "clean-rebuild":
@@ -0,0 +1,158 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/audio-host.ts - Host-side PulseAudio control for Claude Code /voice (macOS-first).
3
+ // Bridges the host microphone into the container over TCP so SoX 'rec' inside the container can capture audio.
4
+ // All actions run on the host; the per-workspace wiring is the .lock audio flag (see dev.ts).
5
+ // =========================================================================================================================================
6
+ import { spawnSync } from "node:child_process";
7
+ import { randomBytes } from "node:crypto";
8
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { AUDIO_TCP_PORT, TOTOPO_DIR } from "./constants.js";
12
+ // Coarse network filter: loopback plus the private (RFC1918) ranges Docker may present container
13
+ // traffic from. This keeps the server off the public internet without guessing the exact source IP
14
+ // a given Docker setup uses (which varies), so /voice connects reliably. Actual access control is the
15
+ // shared cookie (see hostCookiePath): a client on these networks still cannot connect without it.
16
+ const ACL = "127.0.0.1;10.0.0.0/8;172.16.0.0/12;192.168.0.0/16";
17
+ // Host-side daemon control (install/start/stop/test) is automated on macOS only.
18
+ // Other platforms still get accurate running/installed status; setup is documented in the README.
19
+ export const IS_MACOS = process.platform === "darwin";
20
+ // --- Probes ------------------------------------------------------------------------------------------------------------------------------
21
+ // True if a command exists on PATH.
22
+ function have(cmd) {
23
+ return spawnSync("which", [cmd], { stdio: "pipe" }).status === 0;
24
+ }
25
+ // True if pulseaudio is installed on the host.
26
+ function isPulseInstalled() {
27
+ return have("pulseaudio");
28
+ }
29
+ // First line of `pulseaudio --version`, or null when unavailable.
30
+ function pulseVersion() {
31
+ const r = spawnSync("pulseaudio", ["--version"], { encoding: "utf8", stdio: "pipe" });
32
+ if (r.status !== 0 || !r.stdout)
33
+ return null;
34
+ return r.stdout.trim().split("\n")[0] ?? null;
35
+ }
36
+ // True when a PulseAudio daemon is currently running on the host. Safe on any platform.
37
+ export function isAudioServerRunning() {
38
+ return spawnSync("pulseaudio", ["--check"], { stdio: "pipe" }).status === 0;
39
+ }
40
+ // --- Status ------------------------------------------------------------------------------------------------------------------------------
41
+ // Snapshot of the host audio server for the Voice menu.
42
+ export function getStatus() {
43
+ const installed = isPulseInstalled();
44
+ return {
45
+ installed,
46
+ running: isAudioServerRunning(),
47
+ version: installed ? pulseVersion() : null,
48
+ };
49
+ }
50
+ // --- Actions -----------------------------------------------------------------------------------------------------------------------------
51
+ // Install pulseaudio via Homebrew (macOS). Inherits stdio so brew progress is visible.
52
+ export function installPulse() {
53
+ if (isPulseInstalled())
54
+ return { ok: true, message: "pulseaudio is already installed." };
55
+ if (!have("brew"))
56
+ return { ok: false, message: "Homebrew not found. Install it from https://brew.sh then retry." };
57
+ const r = spawnSync("brew", ["install", "pulseaudio"], { stdio: "inherit" });
58
+ if (r.status === 0)
59
+ return { ok: true, message: "pulseaudio installed." };
60
+ return { ok: false, message: "brew install pulseaudio failed." };
61
+ }
62
+ // PulseAudio authenticates native-protocol clients with a 256-byte shared-secret cookie.
63
+ const COOKIE_BYTES = 256;
64
+ // totopo uses its OWN cookie for the container (TCP) path, kept separate from the user's general
65
+ // PulseAudio cookie and never mounting that general credential into the container. It lives at a
66
+ // stable path that survives reboots, so a running container keeps working without a rebuild.
67
+ export function hostCookiePath() {
68
+ return join(homedir(), TOTOPO_DIR, "pulse-cookie");
69
+ }
70
+ // Write a fresh random cookie to `path`, in place (truncating the same file) so a container's
71
+ // read-only bind mount sees the new bytes live - no rebuild needed. 0600: only the host user reads it.
72
+ function writeFreshCookie(path) {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ writeFileSync(path, randomBytes(COOKIE_BYTES), { mode: 0o600 });
75
+ }
76
+ // Ensure the cookie file exists WITHOUT rotating it (create-if-missing). Used at container-create time
77
+ // so the mount target is always valid; never clobbers a cookie a running server is already using.
78
+ export function ensureCookieFile() {
79
+ const path = hostCookiePath();
80
+ if (!existsSync(path))
81
+ writeFreshCookie(path);
82
+ return path;
83
+ }
84
+ // Start the host daemon: capture the Mac mic (coreaudio) and expose it to the container over TCP.
85
+ // module-coreaudio-detect : auto-create sources/sinks for CoreAudio devices (the mic).
86
+ // module-always-sink : guarantee a sink exists (avoids warnings).
87
+ // module-native-protocol-unix : let host-side pactl/parec connect over the local socket (uses the
88
+ // daemon's own default cookie, so testMic is unaffected by our cookie).
89
+ // module-native-protocol-tcp : expose the server to the container over TCP. No auth-anonymous, so
90
+ // clients must present totopo's dedicated cookie (auth-cookie); the IP
91
+ // ACL is defense-in-depth, restricting which hosts may even connect.
92
+ export function startServer() {
93
+ if (!isPulseInstalled())
94
+ return { ok: false, message: "pulseaudio is not installed. Install it first." };
95
+ if (isAudioServerRunning())
96
+ return { ok: true, message: "pulseaudio is already running." };
97
+ // Rotate the dedicated TCP cookie on each cold start: a cookie leaked from a previous session is
98
+ // invalidated here. Written in place so a running container picks it up without a rebuild.
99
+ const cookie = hostCookiePath();
100
+ writeFreshCookie(cookie);
101
+ const modules = [
102
+ "--load=module-coreaudio-detect",
103
+ "--load=module-always-sink",
104
+ "--load=module-native-protocol-unix",
105
+ `--load=module-native-protocol-tcp auth-ip-acl=${ACL} auth-cookie=${cookie} port=${AUDIO_TCP_PORT}`,
106
+ ];
107
+ const r = spawnSync("pulseaudio", ["--daemonize=yes", "-n", "--exit-idle-time=-1", ...modules], { stdio: "pipe" });
108
+ if (r.status !== 0)
109
+ return { ok: false, message: "pulseaudio failed to start. Approve any macOS firewall prompt, then retry." };
110
+ if (isAudioServerRunning())
111
+ return { ok: true, message: `pulseaudio started on TCP ${AUDIO_TCP_PORT}.` };
112
+ return { ok: false, message: "pulseaudio did not come up. Check Console.app logs and retry." };
113
+ }
114
+ // Stop the host daemon. Safe to call when nothing is running.
115
+ export function stopServer() {
116
+ if (!isPulseInstalled())
117
+ return { ok: true, message: "pulseaudio is not installed; nothing to stop." };
118
+ if (!isAudioServerRunning())
119
+ return { ok: true, message: "pulseaudio is not running." };
120
+ spawnSync("pulseaudio", ["--kill"], { stdio: "pipe" });
121
+ if (!isAudioServerRunning())
122
+ return { ok: true, message: "pulseaudio stopped." };
123
+ return { ok: false, message: "Could not stop pulseaudio." };
124
+ }
125
+ // Record ~3s from the default source and inspect it. All-zero capture almost always means the
126
+ // macOS microphone permission was denied. Returns the captured byte count for the menu to report.
127
+ export function testMic() {
128
+ if (!isAudioServerRunning())
129
+ return { ok: false, message: "pulseaudio is not running. Start it first.", bytes: 0 };
130
+ if (!have("parec"))
131
+ return { ok: false, message: "parec not found - reinstall pulseaudio (it ships parec/pactl).", bytes: 0 };
132
+ const r = spawnSync("parec", ["--channels=1", "--rate=16000", "--format=s16le"], {
133
+ timeout: 3000,
134
+ maxBuffer: 16000 * 2 * 5, // ~5s of 16kHz mono s16 headroom
135
+ stdio: ["ignore", "pipe", "ignore"],
136
+ });
137
+ const buf = r.stdout ?? Buffer.alloc(0);
138
+ const bytes = buf.length;
139
+ if (bytes < 1000) {
140
+ return { ok: false, message: `Captured only ${bytes} bytes - the host could not read the microphone source.`, bytes };
141
+ }
142
+ let nonZero = false;
143
+ for (const b of buf) {
144
+ if (b !== 0) {
145
+ nonZero = true;
146
+ break;
147
+ }
148
+ }
149
+ if (!nonZero) {
150
+ return {
151
+ ok: false,
152
+ message: "Captured audio but it was pure silence - the macOS microphone permission is almost certainly denied. " +
153
+ "Approve your terminal under System Settings > Privacy & Security > Microphone, then retry.",
154
+ bytes,
155
+ };
156
+ }
157
+ return { ok: true, message: `Captured ${bytes} bytes of real audio - mic capture works.`, bytes };
158
+ }
@@ -39,6 +39,7 @@ export const LABEL_PROFILE = "totopo.profile";
39
39
  export const LABEL_RUNTIME_ENV = "totopo.runtime-env";
40
40
  export const LABEL_GIT_MODE = "totopo.git-mode";
41
41
  export const LABEL_BUILD_HASH = "totopo.build-hash";
42
+ export const LABEL_AUDIO = "totopo.audio";
42
43
  // Built-in profile names (must match keys in buildDefaultTotopoYaml in totopo-yaml.ts)
43
44
  export const PROFILE = {
44
45
  default: "default",
@@ -62,3 +63,13 @@ export const RUNTIME_ENV = {
62
63
  DISABLE_TELEMETRY: "1", // Container sessions should not phone home
63
64
  DISABLE_UPGRADE_COMMAND: "1", // /upgrade is wrong path inside container; totopo manages CLI version
64
65
  };
66
+ // Audio bridge for Claude Code /voice (opt-in, per-workspace via the .lock audio flag).
67
+ // When enabled, dev.ts injects PULSE_SERVER + AUDIODRIVER and an --add-host so SoX 'rec'
68
+ // inside the container reaches a PulseAudio server running on the host.
69
+ // The host must be host.docker.internal, never 127.0.0.1 (which is the container itself).
70
+ export const AUDIO_TCP_PORT = 4713;
71
+ export const AUDIO_PULSE_SERVER = `tcp:host.docker.internal:${AUDIO_TCP_PORT}`;
72
+ export const AUDIODRIVER_VALUE = "pulseaudio";
73
+ // Where the host PulseAudio cookie is bind-mounted inside the container. PULSE_COOKIE points here so
74
+ // libpulse presents the shared secret; only containers totopo hands the cookie to can authenticate.
75
+ export const AUDIO_COOKIE_CONTAINER_PATH = `${CONTAINER_HOME}/.config/pulse/cookie`;
@@ -421,6 +421,38 @@ export function migrateAddGitMode() {
421
421
  }
422
422
  return migrated;
423
423
  }
424
+ /**
425
+ * Pre-v3.9.0: Add the audio=false field to .lock files. False is the default, so this is a
426
+ * cosmetic write that makes the field visible on disk; runtime behavior is unchanged for
427
+ * existing workspaces (microphone bridging stays off until explicitly enabled). Idempotent -
428
+ * skips files that already have the field. Prints a one-time clack note() when any workspace
429
+ * was newly migrated so users discover the new feature. Returns the count for testing purposes;
430
+ * the registered Migration entry ignores it.
431
+ */
432
+ export function migrateAddAudio() {
433
+ const baseDir = getWorkspacesBaseDir();
434
+ if (!existsSync(baseDir))
435
+ return 0;
436
+ let migrated = 0;
437
+ for (const entry of readdirSync(baseDir)) {
438
+ const lockPath = join(baseDir, entry, LOCK_FILE);
439
+ try {
440
+ const content = readFileSync(lockPath, "utf8");
441
+ if (content.includes(`${LOCK_KEYS.audio}=`))
442
+ continue;
443
+ const trimmed = content.endsWith("\n") ? content : `${content}\n`;
444
+ writeFileSync(lockPath, `${trimmed}${LOCK_KEYS.audio}=false\n`);
445
+ migrated++;
446
+ }
447
+ catch {
448
+ // unreadable -- skip, will surface as a broken workspace elsewhere
449
+ }
450
+ }
451
+ if (migrated > 0) {
452
+ note(`totopo v3.9.0 adds opt-in microphone support for Claude Code's /voice.\nEnable it per workspace via the totopo menu > Manage Workspace > Voice / audio.\nOn macOS totopo can install and run the host audio bridge for you; on Linux/Windows you point it at your own PulseAudio server.`, "Voice / audio");
453
+ }
454
+ return migrated;
455
+ }
424
456
  /**
425
457
  * v3.2.1 and earlier: Remove deprecated fields from totopo.yaml.
426
458
  * - schema_version: redundant, totopo validates with the bundled JSON schema at runtime
@@ -493,6 +525,7 @@ function buildMigrations(cwd, skipAnyConfirmations) {
493
525
  run: () => migrateRemoveDeprecatedYamlFields(cwd),
494
526
  },
495
527
  { from: "v3.4.0", description: "Add git_mode=local to .lock files (preserves pre-v3.4.0 behavior)", run: migrateAddGitMode },
528
+ { from: "v3.9.0", description: "Add audio=false to .lock files (preserves pre-v3.9.0 behavior)", run: migrateAddAudio },
496
529
  ];
497
530
  }
498
531
  /** Run all migrations in order. Called early in bin/totopo.js startup. */
@@ -12,6 +12,7 @@ export const LOCK_KEYS = {
12
12
  workspaceRoot: "root",
13
13
  activeProfile: "profile",
14
14
  gitMode: "git_mode",
15
+ audio: "audio",
15
16
  };
16
17
  /** Reverse lookup: file key → LockFile field name, used during parsing. */
17
18
  const FILE_KEY_TO_FIELD = Object.fromEntries(Object.entries(LOCK_KEYS).map(([field, key]) => [key, field]));
@@ -55,6 +56,7 @@ function parseLockFile(workspaceId) {
55
56
  workspaceRoot: partial.workspaceRoot,
56
57
  activeProfile: partial.activeProfile ?? PROFILE.default,
57
58
  gitMode: partial.gitMode ?? GIT_MODE.local,
59
+ audio: partial.audio ?? "false",
58
60
  };
59
61
  }
60
62
  catch {
@@ -79,6 +81,7 @@ export function writeLockFile(workspaceId, workspaceRoot) {
79
81
  workspaceRoot,
80
82
  activeProfile: existing?.activeProfile ?? PROFILE.default,
81
83
  gitMode: existing?.gitMode ?? GIT_MODE.local,
84
+ audio: existing?.audio ?? "false",
82
85
  });
83
86
  }
84
87
  /** Read the active profile name. Returns null if lock file is missing. */
@@ -107,13 +110,24 @@ export function writeGitMode(workspaceId, gitMode) {
107
110
  return;
108
111
  writeLockFileInternal(workspaceId, { ...existing, gitMode });
109
112
  }
113
+ /** Read the audio (Claude Code /voice) opt-in flag. Defaults to false when unset or lock file is missing. */
114
+ export function readAudio(workspaceId) {
115
+ return parseLockFile(workspaceId)?.audio === "true";
116
+ }
117
+ /** Write the audio opt-in flag. No-op if the lock file is missing. Preserves all other fields. */
118
+ export function writeAudio(workspaceId, audio) {
119
+ const existing = parseLockFile(workspaceId);
120
+ if (!existing)
121
+ return;
122
+ writeLockFileInternal(workspaceId, { ...existing, audio: String(audio) });
123
+ }
110
124
  // --- Workspace directory initialization --------------------------------------------------------------------------------------------------
111
125
  /** Initialize ~/.totopo/workspaces/<workspace_id>/ with lock file and subdirs. */
112
- export function initWorkspaceDir(workspaceId, workspaceRoot, activeProfile = PROFILE.default, gitMode = GIT_MODE.local) {
126
+ export function initWorkspaceDir(workspaceId, workspaceRoot, activeProfile = PROFILE.default, gitMode = GIT_MODE.local, audio = false) {
113
127
  const dir = getWorkspaceDir(workspaceId);
114
128
  mkdirSync(join(dir, AGENTS_DIR), { recursive: true });
115
129
  mkdirSync(join(dir, SHADOWS_DIR), { recursive: true });
116
- writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile, gitMode });
130
+ writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile, gitMode, audio: String(audio) });
117
131
  }
118
132
  // --- Listing -----------------------------------------------------------------------------------------------------------------------------
119
133
  /** List all registered workspace IDs (directories with a .lock file) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.8.0",
3
+ "version": "3.9.0-rc-1",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,15 @@
8
8
  # dockerfile_hook in totopo.yaml profiles.
9
9
  # =============================================================================
10
10
 
11
+ # ---------------------------------------------------------------------------
12
+ # Version pinning policy
13
+ # Infra tools whose major versions can change behavior are pinned exact so that
14
+ # fresh and --no-cache builds stay reproducible; bumping a pin edits this file,
15
+ # busts the build hash, and auto-prompts a rebuild. Deliberately left floating:
16
+ # apt packages (pinned by the Debian trixie release), Node (major-pinned via
17
+ # setup_24.x; minors carry security fixes), and the AI CLIs (always latest).
18
+ # ---------------------------------------------------------------------------
19
+
11
20
  FROM debian:trixie-slim
12
21
  LABEL totopo.managed=true
13
22
 
@@ -22,8 +31,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
22
31
  git curl wget bash zsh make \
23
32
  # Build essentials
24
33
  build-essential pkg-config libssl-dev \
25
- # Utilities
26
- jq unzip zip tree htop procps lsb-release gnupg ca-certificates sox file bubblewrap \
34
+ # Utilities (libsox-fmt-pulse + pulseaudio-utils let SoX 'rec' reach a host PulseAudio server for Claude Code /voice; dormant unless wired at runtime)
35
+ jq unzip zip tree htop procps lsb-release gnupg ca-certificates sox libsox-fmt-pulse pulseaudio-utils file bubblewrap \
27
36
  # Modern search/navigation tools (fd-find installs the binary as 'fdfind'; symlinked to 'fd' below)
28
37
  ripgrep fzf fd-find \
29
38
  # Database clients
@@ -39,10 +48,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
39
48
  RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd
40
49
 
41
50
  # ---------------------------------------------------------------------------
42
- # Layer 3 — yq (GitHub release)
51
+ # Layer 3 — yq (GitHub release, pinned)
43
52
  # ---------------------------------------------------------------------------
44
53
  RUN ARCH=$(dpkg --print-architecture) && \
45
- curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${ARCH}" \
54
+ curl -fsSL "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_${ARCH}" \
46
55
  -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq
47
56
 
48
57
  # ---------------------------------------------------------------------------
@@ -69,10 +78,13 @@ RUN git config --system protocol.allow never && \
69
78
  git config --system protocol.file.allow always
70
79
 
71
80
  # ---------------------------------------------------------------------------
72
- # Layer 7 — Global npm tools (AI CLIs)
81
+ # Layer 7 — Global npm tools (pnpm pinned; AI CLIs always latest)
82
+ # pnpm is pinned exact: pnpm 11 changed where global settings are read from
83
+ # (~/.config/pnpm/config.yaml, no longer ~/.npmrc), so an unpinned major bump
84
+ # can silently break the baked store-dir config.
73
85
  # ---------------------------------------------------------------------------
74
86
  RUN npm install -g \
75
- pnpm \
87
+ pnpm@11.6.0 \
76
88
  opencode-ai \
77
89
  @anthropic-ai/claude-code \
78
90
  @openai/codex \
@@ -2,3 +2,4 @@
2
2
 
3
3
  At the start of every session:
4
4
  - Briefly tell the user they are in a totopo sandbox and mention key limitations (totopo git mode, no host filesystem access outside the workspace).
5
+ - Add a short note that if they want to use or troubleshoot microphone input (Claude Code `/voice`), it is set up from the totopo menu under **Manage Workspace -> Voice / audio** — not from inside the container.