totopo 3.8.1 → 3.9.0
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 +13 -1
- package/bin/totopo.js +5 -1
- package/dist/commands/dev.js +43 -6
- package/dist/commands/menu.js +6 -2
- package/dist/commands/workspace.js +69 -2
- package/dist/lib/audio-host.js +158 -0
- package/dist/lib/constants.js +11 -0
- package/dist/lib/migrate-to-latest.js +33 -0
- package/dist/lib/workspace-identity.js +16 -2
- package/package.json +1 -1
- package/templates/Dockerfile +2 -2
- package/templates/context/responsibilities.md +1 -0
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
|
-
**
|
|
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
|
-
|
|
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":
|
package/dist/commands/dev.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 }),
|
package/dist/commands/menu.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
+
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
package/templates/Dockerfile
CHANGED
|
@@ -31,8 +31,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
31
31
|
git curl wget bash zsh make \
|
|
32
32
|
# Build essentials
|
|
33
33
|
build-essential pkg-config libssl-dev \
|
|
34
|
-
# Utilities
|
|
35
|
-
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 \
|
|
36
36
|
# Modern search/navigation tools (fd-find installs the binary as 'fdfind'; symlinked to 'fd' below)
|
|
37
37
|
ripgrep fzf fd-find \
|
|
38
38
|
# Database clients
|
|
@@ -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.
|