totopo 3.9.0 → 3.10.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
@@ -81,8 +81,8 @@ On every run, totopo shows the workspace menu:
81
81
 
82
82
  - **Open session** — start or resume the dev container and connect
83
83
  - **Stop container** — stop the running container
84
- - **Manage Workspace** — git mode, shadow paths, rebuild, reset config
85
- - **Manage totopo** — multi-workspace management (stop containers, clear memory, uninstall)
84
+ - **Settings** — git mode, shadow paths, voice, rebuild, reset config
85
+ - **Advanced** — multi-workspace management (stop containers, clear memory, uninstall)
86
86
 
87
87
  ### Working directory
88
88
 
@@ -106,7 +106,7 @@ Every session runs inside a Docker container. Your code is bind-mounted from the
106
106
 
107
107
  ### Git Modes
108
108
 
109
- Each workspace has a git mode (set via **Manage Workspace > Git mode**) that controls what git operations are permitted inside the container:
109
+ Each workspace has a git mode (set via **Settings > Git mode**) that controls what git operations are permitted inside the container:
110
110
 
111
111
  | Mode | Local mutations | Remote (push/pull/fetch/clone) |
112
112
  |---|---|---|
@@ -148,7 +148,7 @@ profiles:
148
148
 
149
149
  New workspaces ship with the `default` profile active and an `extended` profile (Go, Java, Rust, Bun) included as a commented-out template — uncomment to enable. When multiple profiles are defined, totopo prompts you to pick one at session start (the choice is remembered). A profile change triggers a container rebuild on the next session.
150
150
 
151
- The base image is defined in [`templates/Dockerfile`](templates/Dockerfile) — inspect it to see what's already included before adding your own layers. To force a fully fresh build (no Docker layer cache), use **Manage Workspace > Clean rebuild**.
151
+ The base image is defined in [`templates/Dockerfile`](templates/Dockerfile) — inspect it to see what's already included before adding your own layers. To force a fully fresh build (no Docker layer cache), use **Settings > Clean rebuild**.
152
152
 
153
153
  ### Shadow Paths
154
154
 
@@ -161,7 +161,7 @@ shadow_paths:
161
161
  - .env* # hides .env, .env.local, etc. from agents
162
162
  ```
163
163
 
164
- Patterns follow gitignore syntax — patterns without a `/` match at any depth. Manage via **Manage Workspace > Shadow paths** or edit `totopo.yaml` directly. Changes take effect on the next session.
164
+ Patterns follow gitignore syntax — patterns without a `/` match at any depth. Manage via **Settings > Shadow paths** or edit `totopo.yaml` directly. Changes take effect on the next session.
165
165
 
166
166
  Git-tracked paths are skipped to avoid worktree diversions. Shadowing them has no privacy benefit anyway since agents can `git show` tracked content. To hide a file, untrack it and add it to `.gitignore` first.
167
167
 
@@ -218,7 +218,7 @@ Agent session data (conversation history, settings) is stored per workspace and
218
218
  └── codex/ # mounted as ~/.codex/ inside the container
219
219
  ```
220
220
 
221
- To clear memory: `npx totopo` → **Manage totopo > Clear agent memory**.
221
+ To clear memory: `npx totopo` → **Advanced > Clear agent memory**.
222
222
 
223
223
  ## What Gets Installed
224
224
 
@@ -238,11 +238,11 @@ To clear memory: `npx totopo` → **Manage totopo > Clear agent memory**.
238
238
 
239
239
  ## Voice Mode (microphone)
240
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**.
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 **Settings → Voice / audio**.
242
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.
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. For hands-off control, turn **Auto start/stop** on in the same menu: totopo then starts the server when a voice-enabled session opens and stops it once your last session exits, so you never start or stop it by hand.
244
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.
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/global/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
246
 
247
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
248
 
@@ -252,7 +252,7 @@ Claude Code's `/voice` records from a mic via SoX, but a container has none (on
252
252
 
253
253
  **Single machine** — `~/.totopo/` is local. Switching machines requires re-running setup in each workspace.
254
254
 
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).
255
+ **Voice mode / audio** — `/voice` needs a microphone, which a container does not have by default. Enable it under **Settings → Voice / audio**; see [Voice Mode](#voice-mode-microphone).
256
256
 
257
257
  **Shift+Enter not working in VS Code terminal** — add this to your VS Code keybindings (`Cmd+Shift+P` → "Open Keyboard Shortcuts (JSON)"):
258
258
 
package/bin/totopo.js CHANGED
@@ -9,12 +9,12 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { cancel, confirm, isCancel, log, select } from "@clack/prompts";
12
+ import { run as advancedMenu } from "../dist/commands/advanced.js";
12
13
  import { run as dev } from "../dist/commands/dev.js";
13
14
  import { run as doctor } from "../dist/commands/doctor.js";
14
- 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
- import { resetImage, stop, run as workspaceMenu } from "../dist/commands/workspace.js";
17
+ import { resetImage, run as settingsMenu, stop } from "../dist/commands/settings.js";
18
18
  import { isAudioServerRunning } from "../dist/lib/audio-host.js";
19
19
  import { GITHUB_README_URL, repairTotopoYaml } from "../dist/lib/totopo-yaml.js";
20
20
  import { deriveContainerName, findTotopoYamlDir, listWorkspaceIds, resolveWorkspace } from "../dist/lib/workspace-identity.js";
@@ -113,15 +113,15 @@ if (!workspace) {
113
113
  message: "What would you like to do?",
114
114
  options: [
115
115
  { value: "setup", label: "Set up totopo for this directory" },
116
- { value: "manage", label: "Manage totopo →" },
116
+ { value: "advanced", label: "Advanced" },
117
117
  ],
118
118
  });
119
119
  if (isCancel(choice)) {
120
120
  cancel();
121
121
  process.exit(0);
122
122
  }
123
- if (choice === "manage") {
124
- await globalMenu();
123
+ if (choice === "advanced") {
124
+ await advancedMenu();
125
125
  process.exit(0);
126
126
  }
127
127
  }
@@ -132,7 +132,7 @@ if (!workspace) {
132
132
  }
133
133
 
134
134
  // --- Doctor (silent pre-check) -----------------------------------------------------------------------------------------------------------
135
- const doctorResult = await doctor(null, false);
135
+ const doctorResult = await doctor(false);
136
136
  if (!doctorResult.ok) {
137
137
  console.error(" Fix the issues above and re-run totopo.");
138
138
  console.error("");
@@ -166,7 +166,7 @@ while (showMenu) {
166
166
  await stop(workspace.containerName);
167
167
  break;
168
168
  case "settings": {
169
- const settingsResult = await workspaceMenu(workspace);
169
+ const settingsResult = await settingsMenu(workspace);
170
170
  if (settingsResult === "rebuild") {
171
171
  await resetImage(workspace.containerName);
172
172
  await dev(packageDir, workspace);
@@ -178,8 +178,8 @@ while (showMenu) {
178
178
  }
179
179
  break;
180
180
  }
181
- case "manage-totopo": {
182
- const result = await globalMenu(workspace.workspaceId);
181
+ case "advanced": {
182
+ const result = await advancedMenu(workspace.workspaceId);
183
183
  if (result === "back") showMenu = true;
184
184
  break;
185
185
  }
@@ -1,5 +1,5 @@
1
1
  // =========================================================================================================================================
2
- // src/commands/global.ts - Manage totopo menu (global, all workspaces)
2
+ // src/commands/advanced.ts - Advanced menu: less-common operations (stop containers, clear agent memory, remove images, uninstall)
3
3
  // =========================================================================================================================================
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync } from "node:fs";
@@ -239,11 +239,11 @@ async function uninstallTotopo() {
239
239
  }
240
240
  outro("totopo uninstalled. Re-run npx totopo to set up again.");
241
241
  }
242
- // --- Manage totopo menu ------------------------------------------------------------------------------------------------------------------
242
+ // --- Advanced menu -----------------------------------------------------------------------------------------------------------------------
243
243
  export async function run(currentWorkspaceId) {
244
244
  while (true) {
245
245
  const action = await select({
246
- message: "Manage totopo:",
246
+ message: "Advanced:",
247
247
  options: [
248
248
  { value: "stop-containers", label: "Stop containers", hint: "pick running containers" },
249
249
  { value: "clear-memory", label: "Clear agent memory", hint: "pick workspaces to clear" },
@@ -8,9 +8,10 @@ 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 { 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";
11
+ import { connectedSessionCount, containerSessionCount, ensureCookieFile, IS_MACOS, isAudioServerRunning, startServer, stopServer, } from "../lib/audio-host.js";
12
+ import { AUDIO_COOKIE_CONTAINER_PATH, AUDIO_MODE, 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";
13
13
  import { buildDockerfile, buildImageWithTempfile, computeBuildHash } from "../lib/dockerfile-builder.js";
14
+ import { readAudioMode } from "../lib/global-config.js";
14
15
  import { isImageStale } from "../lib/migrate-to-latest.js";
15
16
  import { buildPnpmStoreMountArgs } from "../lib/pnpm-store.js";
16
17
  import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
@@ -34,6 +35,15 @@ async function promptWorkdir(workspaceDir, cwd) {
34
35
  }
35
36
  return choice === "here" ? `${CONTAINER_WORKSPACE}/${relPath}` : CONTAINER_WORKSPACE;
36
37
  }
38
+ // --- Countdown helper --------------------------------------------------------------------------------------------------------------------
39
+ // Print a message, then tick down one line per second. Used so a transient warning (e.g. the host audio
40
+ // server failed to auto-start) stays on screen long enough to read before the session connects anyway.
41
+ async function countdown(seconds, message) {
42
+ for (let remaining = seconds; remaining > 0; remaining--) {
43
+ log.info(`${message} in ${remaining}...`);
44
+ await new Promise((resolve) => setTimeout(resolve, 1000));
45
+ }
46
+ }
37
47
  // --- Profile selection -------------------------------------------------------------------------------------------------------------------
38
48
  async function selectProfile(ctx, profiles) {
39
49
  const profileNames = Object.keys(profiles);
@@ -318,6 +328,20 @@ export async function run(packageDir, ctx, options) {
318
328
  const gitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
319
329
  // --- Audio bridge opt-in (per-workspace, host-side .lock) ----------------------------------------------------------------------------
320
330
  const audio = readAudio(ctx.workspaceId);
331
+ // --- Auto-start host audio server (automatic mode, macOS) ----------------------------------------------------------------------------
332
+ // When wiring is on and the workspace is in automatic mode, bring the host server up before the
333
+ // container starts so the cookie it rotates is already in place for the read-only mount below
334
+ // (ensureCookieFile then no-ops). A failure never blocks the session - warn and count down so the
335
+ // message is readable, then connect anyway.
336
+ if (IS_MACOS && audio && readAudioMode() === AUDIO_MODE.automatic && !isAudioServerRunning()) {
337
+ const res = startServer();
338
+ if (res.ok)
339
+ log.info("Host audio server started (voice input ready).");
340
+ else {
341
+ log.warn(res.message);
342
+ await countdown(3, "Continuing without the audio server");
343
+ }
344
+ }
321
345
  // Ensure totopo's dedicated host cookie exists so the read-only mount target is always valid; the
322
346
  // host server rotates it on each cold start. Creating it here (when absent) avoids any need to
323
347
  // recreate the container after the server first starts.
@@ -383,5 +407,33 @@ export async function run(packageDir, ctx, options) {
383
407
  const exec = spawnSync("docker", ["exec", "-it", "-w", workdir, containerName, "bash", "--login"], {
384
408
  stdio: "inherit",
385
409
  });
410
+ // --- Auto-stop host audio server (automatic mode, macOS) -----------------------------------------------------------------------------
411
+ // Control returns here synchronously when the user exits the shell. In automatic mode, stop the
412
+ // global host server only when no totopo session anywhere is still connected (the just-exited shell
413
+ // is already reaped, so 0 is the all-clear). Conservative by design: a lingering session keeps it up.
414
+ if (IS_MACOS && audio && readAudioMode() === AUDIO_MODE.automatic && isAudioServerRunning() && connectedSessionCount() === 0) {
415
+ const res = stopServer();
416
+ if (res.ok)
417
+ log.info("Host audio server stopped (no active sessions).");
418
+ else
419
+ log.warn(res.message);
420
+ }
421
+ // --- Offer to stop this workspace's container (last shell closed) --------------------------------------------------------------------
422
+ // The container itself keeps running (sleep infinity) after the shell exits. When this was the last
423
+ // shell to it, offer to stop it to free memory. Stop-only (no rm) so the next session resumes fast
424
+ // via the "exited" -> docker start path. Runs after the global audio auto-stop above; all platforms.
425
+ if (containerSessionCount(containerName) === 0) {
426
+ const stopNow = await confirm({
427
+ message: "Last session to this container closed. Stop it to free memory? (resumes fast next time)",
428
+ initialValue: true,
429
+ });
430
+ if (!isCancel(stopNow) && stopNow) {
431
+ log.step("Stopping container...");
432
+ spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
433
+ log.info("Container stopped - memory freed; it resumes on your next session.");
434
+ }
435
+ }
436
+ // Trailing blank line so the last log does not sit flush against the next shell prompt.
437
+ process.stdout.write("\n");
386
438
  process.exit(exec.status ?? 0);
387
439
  }
@@ -7,13 +7,14 @@ import { spawnSync } from "node:child_process";
7
7
  import { log, outro } from "@clack/prompts";
8
8
  // Returns true if the given CLI tool is resolvable in the system PATH
9
9
  function commandExists(cmd) {
10
- const r = spawnSync("command", ["-v", cmd], {
10
+ // Single command string (not an args array) under shell: true sidesteps Node's DEP0190; cmd is a hardcoded internal name.
11
+ const r = spawnSync(`command -v ${cmd}`, {
11
12
  shell: true,
12
13
  encoding: "utf8",
13
14
  });
14
15
  return r.status === 0;
15
16
  }
16
- export async function run(_workspaceDir, verbose) {
17
+ export async function run(verbose) {
17
18
  const errors = [];
18
19
  function check(label, ok, detail) {
19
20
  if (ok) {
@@ -6,20 +6,37 @@ import { existsSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { styleText } from "node:util";
8
8
  import { box, cancel, isCancel, select } from "@clack/prompts";
9
- import { PROFILE } from "../lib/constants.js";
10
- import { readActiveProfile } from "../lib/workspace-identity.js";
9
+ import { IS_MACOS } from "../lib/audio-host.js";
10
+ import { AUDIO_MODE, PROFILE } from "../lib/constants.js";
11
+ import { readAudioMode } from "../lib/global-config.js";
12
+ import { readActiveProfile, readAudio } from "../lib/workspace-identity.js";
11
13
  export async function run(args) {
12
14
  const { ctx, workspaceRunning, audioServerRunning, version } = args;
13
15
  // --- Read workspace config -----------------------------------------------------------------------------------------------------------
14
16
  const activeProfile = readActiveProfile(ctx.workspaceId) ?? PROFILE.default;
15
17
  const hasGit = existsSync(join(ctx.workspaceRoot, ".git"));
18
+ const audioWiring = readAudio(ctx.workspaceId);
16
19
  // --- Status box ----------------------------------------------------------------------------------------------------------------------
17
20
  const containerStatus = workspaceRunning ? "running" : "stopped";
18
21
  const gitNotice = hasGit ? "" : `\n${styleText("yellow", "●")} no git — agent changes are not tracked`;
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
- : "";
22
+ // Surface the host audio server. When this workspace has voice wiring on, show its state at a glance
23
+ // (running / not running). When wiring is off but the global server happens to be up, still nudge the
24
+ // user to stop it - totopo never stops it on its own.
25
+ const audioRunning = `\n${styleText("yellow", "●")} audio server running (Settings › Voice / audio)`;
26
+ const audioStopped = `\n${styleText("gray", "●")} audio server not running (Settings › Voice / audio)`;
27
+ // In automatic mode (macOS) totopo starts the server when a session opens, so a "not running" notice
28
+ // is just noise - suppress it. In manual mode (or off macOS) the user starts it, so the nudge stays.
29
+ const autoStartsServer = IS_MACOS && readAudioMode() === AUDIO_MODE.automatic;
30
+ let audioNotice = "";
31
+ if (audioWiring) {
32
+ if (audioServerRunning)
33
+ audioNotice = audioRunning;
34
+ else if (!autoStartsServer)
35
+ audioNotice = audioStopped;
36
+ }
37
+ else if (audioServerRunning) {
38
+ audioNotice = audioRunning;
39
+ }
23
40
  box(`workspace: ${ctx.workspaceId}\nprofile: ${activeProfile}\ncontainer: ${containerStatus}${gitNotice}${audioNotice}`, ` totopo v${version} `, {
24
41
  contentAlign: "left",
25
42
  titleAlign: "center",
@@ -29,8 +46,8 @@ export async function run(args) {
29
46
  const options = [
30
47
  { value: "dev", label: "Open session", hint: "start or resume the dev container" },
31
48
  ...(workspaceRunning ? [{ value: "stop", label: "Stop container", hint: "stops this workspace's container" }] : []),
32
- { value: "settings", label: "Manage Workspace", hint: "shadow paths, rebuild, reset config" },
33
- { value: "manage-totopo", label: "Manage totopo →", hint: "stop, clear, remove, uninstall" },
49
+ { value: "settings", label: "Settings", hint: "git mode, shadow paths, voice, rebuild" },
50
+ { value: "advanced", label: "Advanced", hint: "stop, clear, remove, uninstall" },
34
51
  { value: "help", label: "Help", hint: "official docs" },
35
52
  { value: "quit", label: "Quit" },
36
53
  ];
@@ -1,11 +1,12 @@
1
1
  // =========================================================================================================================================
2
- // src/commands/workspace.ts - Manage Workspace submenu: shadow paths, rebuild, reset, stop, reset-image
2
+ // src/commands/settings.ts - Settings submenu: git mode, shadow paths, voice, rebuild, reset config
3
3
  // =========================================================================================================================================
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
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
+ import { AUDIO_MODE, AUDIO_TCP_PORT, GIT_MODE } from "../lib/constants.js";
9
+ import { readAudioMode, writeAudioMode } from "../lib/global-config.js";
9
10
  import { countPatternHits } from "../lib/shadows.js";
10
11
  import { buildDefaultTotopoYaml, readTotopoYaml, writeTotopoYaml } from "../lib/totopo-yaml.js";
11
12
  import { readAudio, readGitMode, writeAudio, writeGitMode } from "../lib/workspace-identity.js";
@@ -166,10 +167,14 @@ async function gitModeMenu(ctx) {
166
167
  async function audioMenu(ctx) {
167
168
  while (true) {
168
169
  const wiring = readAudio(ctx.workspaceId);
170
+ const mode = readAudioMode();
169
171
  const status = getStatus();
170
172
  const serverLine = !status.installed ? "not installed" : status.running ? `running on TCP ${AUDIO_TCP_PORT}` : "installed, stopped";
173
+ // The server-control mode only matters where totopo manages the server (macOS).
174
+ const modeLine = IS_MACOS ? `\nmode: ${mode}` : "";
171
175
  note(`wiring: ${wiring ? "enabled" : "disabled"} (this workspace)\n` +
172
176
  `host server: ${serverLine}` +
177
+ modeLine +
173
178
  (status.version ? `\nversion: ${status.version}` : ""), "Voice / audio");
174
179
  log.message("Claude Code /voice needs a microphone, which the container does not have.\n" +
175
180
  "Enable wiring (per-workspace) and run a host PulseAudio server that streams your mic in.\n" +
@@ -182,6 +187,11 @@ async function audioMenu(ctx) {
182
187
  { value: "toggle", label: wiring ? "Disable wiring" : "Enable wiring", hint: "PulseAudio env for this workspace's container" },
183
188
  ];
184
189
  if (IS_MACOS) {
190
+ options.push({
191
+ value: "mode",
192
+ label: `Auto start/stop: ${mode === AUDIO_MODE.automatic ? "on" : "off"}`,
193
+ hint: "auto-start on session, stop on last exit",
194
+ });
185
195
  if (!status.installed)
186
196
  options.push({ value: "install", label: "Install pulseaudio", hint: "via Homebrew" });
187
197
  if (status.installed && !status.running)
@@ -202,6 +212,16 @@ async function audioMenu(ctx) {
202
212
  await promptStopContainer(ctx);
203
213
  continue;
204
214
  }
215
+ if (action === "mode") {
216
+ // The host server is a single shared resource, so this mode is host-global. It only changes
217
+ // server lifecycle behavior, not container config, so no rebuild prompt.
218
+ const next = mode === AUDIO_MODE.automatic ? AUDIO_MODE.manual : AUDIO_MODE.automatic;
219
+ writeAudioMode(next);
220
+ log.success(next === AUDIO_MODE.automatic
221
+ ? "Automatic mode on - opening a session starts the host audio server; exiting stops it when no other session is connected."
222
+ : "Automatic mode off - start and stop the host audio server yourself.");
223
+ continue;
224
+ }
205
225
  let result;
206
226
  if (action === "install") {
207
227
  result = installPulse();
@@ -264,7 +284,7 @@ async function resetTotopoYaml(ctx) {
264
284
  log.success("totopo.yaml reset to defaults.");
265
285
  await promptStopContainer(ctx);
266
286
  }
267
- // --- Manage Workspace submenu ------------------------------------------------------------------------------------------------------------
287
+ // --- Settings submenu --------------------------------------------------------------------------------------------------------------------
268
288
  export async function run(ctx) {
269
289
  while (true) {
270
290
  const currentGitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
@@ -277,7 +297,7 @@ export async function run(ctx) {
277
297
  { value: "reset", label: "Reset config", hint: "restore totopo.yaml to defaults" },
278
298
  { value: "back", label: "← Back" },
279
299
  ];
280
- const action = await select({ message: "Manage Workspace:", options });
300
+ const action = await select({ message: "Settings:", options });
281
301
  if (isCancel(action) || action === "back") {
282
302
  return "back";
283
303
  }
@@ -8,7 +8,7 @@ import { randomBytes } from "node:crypto";
8
8
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
- import { AUDIO_TCP_PORT, TOTOPO_DIR } from "./constants.js";
11
+ import { AUDIO_TCP_PORT, CONTAINER_NAME_PREFIX, GLOBAL_DIR, PULSE_COOKIE_FILE, TOTOPO_DIR } from "./constants.js";
12
12
  // Coarse network filter: loopback plus the private (RFC1918) ranges Docker may present container
13
13
  // traffic from. This keeps the server off the public internet without guessing the exact source IP
14
14
  // a given Docker setup uses (which varies), so /voice connects reliably. Actual access control is the
@@ -37,6 +37,34 @@ function pulseVersion() {
37
37
  export function isAudioServerRunning() {
38
38
  return spawnSync("pulseaudio", ["--check"], { stdio: "pipe" }).status === 0;
39
39
  }
40
+ // Count live interactive sessions in a single container. dev.ts connects each with
41
+ // `docker exec -it ... bash --login`, so a `bash --login` process is the proxy for one live session.
42
+ // At exit the just-left shell is already reaped before this runs, so the last exit reaches 0.
43
+ // Over-counting (e.g. an agent that spawns its own `bash --login`) only keeps things alive, which is
44
+ // safe; a non-zero/errored `docker exec` (container gone) returns 0.
45
+ export function containerSessionCount(containerName) {
46
+ const r = spawnSync("docker", ["exec", containerName, "pgrep", "-fc", "--", "bash --login"], { encoding: "utf8", stdio: "pipe" });
47
+ const n = Number.parseInt((r.stdout ?? "").trim(), 10);
48
+ return Number.isFinite(n) ? n : 0;
49
+ }
50
+ // Sum live interactive sessions across ALL totopo containers (delegates to containerSessionCount).
51
+ // The host server is shared by every workspace, so automatic-mode auto-stop fires only at 0 - no
52
+ // session anywhere. A hard-killed host process (SIGKILL / closed window) skips the exit check and
53
+ // leaves the server running.
54
+ export function connectedSessionCount() {
55
+ const ps = spawnSync("docker", ["ps", "--filter", `name=${CONTAINER_NAME_PREFIX}`, "--format", "{{.Names}}"], {
56
+ encoding: "utf8",
57
+ stdio: "pipe",
58
+ });
59
+ if (ps.status !== 0)
60
+ return 0;
61
+ const names = (ps.stdout ?? "").trim().split("\n").filter(Boolean);
62
+ let total = 0;
63
+ for (const name of names) {
64
+ total += containerSessionCount(name);
65
+ }
66
+ return total;
67
+ }
40
68
  // --- Status ------------------------------------------------------------------------------------------------------------------------------
41
69
  // Snapshot of the host audio server for the Voice menu.
42
70
  export function getStatus() {
@@ -62,10 +90,11 @@ export function installPulse() {
62
90
  // PulseAudio authenticates native-protocol clients with a 256-byte shared-secret cookie.
63
91
  const COOKIE_BYTES = 256;
64
92
  // 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
93
+ // PulseAudio cookie and never mounting that general credential into the container. It lives under the
94
+ // host-global ~/.totopo/global/ dir (the server is a single shared resource, not per-workspace) at a
66
95
  // stable path that survives reboots, so a running container keeps working without a rebuild.
67
96
  export function hostCookiePath() {
68
- return join(homedir(), TOTOPO_DIR, "pulse-cookie");
97
+ return join(homedir(), TOTOPO_DIR, GLOBAL_DIR, PULSE_COOKIE_FILE);
69
98
  }
70
99
  // Write a fresh random cookie to `path`, in place (truncating the same file) so a container's
71
100
  // read-only bind mount sees the new bytes live - no rebuild needed. 0600: only the host user reads it.
@@ -10,6 +10,7 @@ export const PACKAGE_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.ur
10
10
  export const TOTOPO_DIR = ".totopo";
11
11
  export const WORKSPACES_DIR = "workspaces";
12
12
  export const PROJECTS_DIR = "projects"; // legacy v3-rc-1/rc-2; only referenced in migration
13
+ export const GLOBAL_DIR = "global"; // host-global state not tied to a workspace (config + pulse cookie)
13
14
  // Workspace cache subdirectories (under ~/.totopo/workspaces/<id>/)
14
15
  export const AGENTS_DIR = "agents";
15
16
  export const SHADOWS_DIR = "shadows";
@@ -18,6 +19,8 @@ export const PNPM_STORE_DIR = "pnpm-store";
18
19
  export const TOTOPO_YAML = "totopo.yaml";
19
20
  export const LOCK_FILE = ".lock";
20
21
  export const GLOBAL_ENV_FILE = ".env"; // legacy global key file; only referenced in migration
22
+ export const GLOBAL_CONFIG_FILE = "config"; // ~/.totopo/global/config - key=value host-global settings
23
+ export const PULSE_COOKIE_FILE = "pulse-cookie"; // ~/.totopo/global/pulse-cookie - dedicated PulseAudio TCP cookie
21
24
  // Workspace ID constraints (must match schema/totopo.schema.json)
22
25
  export const WORKSPACE_ID_MIN = 2;
23
26
  export const WORKSPACE_ID_MAX = 48;
@@ -73,3 +76,10 @@ export const AUDIODRIVER_VALUE = "pulseaudio";
73
76
  // Where the host PulseAudio cookie is bind-mounted inside the container. PULSE_COOKIE points here so
74
77
  // libpulse presents the shared secret; only containers totopo hands the cookie to can authenticate.
75
78
  export const AUDIO_COOKIE_CONTAINER_PATH = `${CONTAINER_HOME}/.config/pulse/cookie`;
79
+ // Host audio server control mode (host-global, stored in ~/.totopo/global/config). manual: the user starts and stops
80
+ // the host server from the Voice/audio menu. automatic: totopo starts it when a session opens and stops
81
+ // it when the last connected session exits. Automation is macOS-only (the platform where totopo manages
82
+ // PulseAudio), so automatic mode is offered only there. Defaults to manual. Defined directly here (unlike
83
+ // GIT_MODE) because there is no container-side consumer that would need runtime-constants.mjs.
84
+ export const AUDIO_MODE = { manual: "manual", automatic: "automatic" };
85
+ export const AUDIO_MODES = Object.values(AUDIO_MODE);
@@ -0,0 +1,63 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/global-config.ts - Host-global settings store (not tied to any workspace)
3
+ // Lives at ~/.totopo/global/config as key=value lines, mirroring the per-workspace .lock idiom.
4
+ // The host audio server is a single shared resource, so its control mode is global, not per-workspace.
5
+ // =========================================================================================================================================
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { AUDIO_MODE, AUDIO_MODES, GLOBAL_CONFIG_FILE, GLOBAL_DIR, TOTOPO_DIR } from "./constants.js";
10
+ // --- Keys --------------------------------------------------------------------------------------------------------------------------------
11
+ /** Field names mapped to the keys written in the global config file. */
12
+ export const GLOBAL_CONFIG_KEYS = {
13
+ audioMode: "audio_mode",
14
+ };
15
+ // --- Path --------------------------------------------------------------------------------------------------------------------------------
16
+ /** Absolute path to the global config file - ~/.totopo/global/config */
17
+ export function globalConfigPath() {
18
+ return join(homedir(), TOTOPO_DIR, GLOBAL_DIR, GLOBAL_CONFIG_FILE);
19
+ }
20
+ // --- Parse / write -----------------------------------------------------------------------------------------------------------------------
21
+ // Parse the config into an ordered map of raw key=value pairs. Returns an empty map when the file is
22
+ // missing or unreadable - absence is not an error, it just means defaults apply. Unknown keys are kept
23
+ // so a newer totopo's settings survive a write by an older one.
24
+ function parseGlobalConfig() {
25
+ const config = new Map();
26
+ try {
27
+ const lines = readFileSync(globalConfigPath(), "utf8")
28
+ .trimEnd()
29
+ .split("\n")
30
+ .map((l) => l.trim())
31
+ .filter(Boolean);
32
+ for (const line of lines) {
33
+ const eq = line.indexOf("=");
34
+ if (eq === -1)
35
+ continue;
36
+ config.set(line.slice(0, eq), line.slice(eq + 1));
37
+ }
38
+ }
39
+ catch {
40
+ // Missing or unreadable - treat as empty.
41
+ }
42
+ return config;
43
+ }
44
+ // Write the raw key=value map back to ~/.totopo/global/config, creating ~/.totopo/global/ on demand.
45
+ // Unlike the per-workspace lock (which no-ops when missing), the global config has no init step, so it
46
+ // is created lazily on the first write.
47
+ function writeGlobalConfig(config) {
48
+ mkdirSync(join(homedir(), TOTOPO_DIR, GLOBAL_DIR), { recursive: true });
49
+ const content = `${[...config].map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
50
+ writeFileSync(globalConfigPath(), content);
51
+ }
52
+ // --- Audio mode --------------------------------------------------------------------------------------------------------------------------
53
+ /** Read the host audio server control mode. Defaults to manual when unset, missing, or unrecognized. */
54
+ export function readAudioMode() {
55
+ const value = parseGlobalConfig().get(GLOBAL_CONFIG_KEYS.audioMode);
56
+ return value !== undefined && AUDIO_MODES.includes(value) ? value : AUDIO_MODE.manual;
57
+ }
58
+ /** Write the host audio server control mode. Creates the config file on demand and preserves all other keys. */
59
+ export function writeAudioMode(audioMode) {
60
+ const config = parseGlobalConfig();
61
+ config.set(GLOBAL_CONFIG_KEYS.audioMode, audioMode);
62
+ writeGlobalConfig(config);
63
+ }
@@ -27,7 +27,7 @@ import { homedir } from "node:os";
27
27
  import { join } from "node:path";
28
28
  import { confirm, isCancel, log, note } from "@clack/prompts";
29
29
  import { load as loadYaml } from "js-yaml";
30
- import { AGENTS_DIR, GIT_MODE, LABEL_BUILD_HASH, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
30
+ import { AGENTS_DIR, CONTAINER_NAME_PREFIX, GIT_MODE, GLOBAL_DIR, LABEL_BUILD_HASH, LOCK_FILE, PROFILE, PULSE_COOKIE_FILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
31
31
  import { safeRmSync } from "./safe-rm.js";
32
32
  import { buildDefaultTotopoYaml, readTotopoYaml, slugifyForWorkspaceId, validateWorkspaceId, writeTotopoYaml, } from "./totopo-yaml.js";
33
33
  import { findTotopoYamlDir, getWorkspacesBaseDir, initWorkspaceDir, LOCK_KEYS } from "./workspace-identity.js";
@@ -417,7 +417,7 @@ export function migrateAddGitMode() {
417
417
  }
418
418
  }
419
419
  if (migrated > 0) {
420
- note(`totopo v3.4.0 introduces git modes for workspaces.\nDefault is 'local' (previous behavior — local commits allowed, remote blocked).\nTwo opt-in modes are available: 'strict' (read-only, all mutations blocked) and 'unrestricted' (no totopo-enforced restrictions).\nSwitch via the totopo menu > Manage Workspace > Git mode.`, "Git modes");
420
+ note(`totopo v3.4.0 introduces git modes for workspaces.\nDefault is 'local' (previous behavior — local commits allowed, remote blocked).\nTwo opt-in modes are available: 'strict' (read-only, all mutations blocked) and 'unrestricted' (no totopo-enforced restrictions).\nSwitch via the totopo menu > Settings > Git mode.`, "Git modes");
421
421
  }
422
422
  return migrated;
423
423
  }
@@ -449,10 +449,58 @@ export function migrateAddAudio() {
449
449
  }
450
450
  }
451
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");
452
+ note(`totopo v3.9.0 adds opt-in microphone support for Claude Code's /voice.\nEnable it per workspace via the totopo menu > Settings > 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
453
  }
454
454
  return migrated;
455
455
  }
456
+ /**
457
+ * Pre-v3.10.0 -> latest: Move the dedicated PulseAudio cookie from ~/.totopo/pulse-cookie into the new
458
+ * host-global ~/.totopo/global/ dir. The cookie is live bind-mounted into running containers, so it is
459
+ * never moved while a container could hold it open: if any totopo container is running we ask to stop
460
+ * them first (interactive) or defer to the next run (non-interactive). Most users never enabled audio
461
+ * and so have no cookie - for them this is a no-op. Idempotent: once moved, the source is gone.
462
+ */
463
+ async function migrateMoveAudioCookie(interactive) {
464
+ // A path (source): hardcode the literal so the migration still finds the old location later.
465
+ const oldPath = join(homedir(), ".totopo", "pulse-cookie");
466
+ if (!existsSync(oldPath))
467
+ return;
468
+ const globalDir = join(homedir(), TOTOPO_DIR, GLOBAL_DIR);
469
+ const newPath = join(globalDir, PULSE_COOKIE_FILE);
470
+ // The cookie is live-mounted into running containers; never move it out from under one. A failed or
471
+ // absent docker means nothing can be running, so treat that as zero containers.
472
+ const ps = spawnSync("docker", ["ps", "--filter", `name=${CONTAINER_NAME_PREFIX}`, "--format", "{{.Names}}"], {
473
+ encoding: "utf8",
474
+ stdio: "pipe",
475
+ });
476
+ const running = ps.status === 0 ? (ps.stdout ?? "").trim().split("\n").filter(Boolean) : [];
477
+ if (running.length > 0) {
478
+ if (!interactive)
479
+ return; // Defer - re-runs next startup when confirmations are allowed.
480
+ log.warn(`The audio cookie is moving to ~/.totopo/global/, but ${running.length} totopo container(s) are using it.\n` +
481
+ " They must be stopped so the cookie can move cleanly (they are recreated on next session).");
482
+ const shouldStop = await confirm({ message: "Stop running totopo containers to migrate the audio cookie?", initialValue: true });
483
+ if (isCancel(shouldStop) || !shouldStop) {
484
+ log.info("Kept containers running - the audio cookie will migrate on next run.");
485
+ return;
486
+ }
487
+ for (const name of running) {
488
+ spawnSync("docker", ["stop", name], { stdio: "pipe" });
489
+ spawnSync("docker", ["rm", name], { stdio: "pipe" });
490
+ }
491
+ log.info("Containers stopped - they will be recreated on next session.");
492
+ }
493
+ // Nothing is mounting the cookie now, so move it once. If a cookie already exists at the destination
494
+ // (e.g. the server cold-started since), the destination is authoritative - drop the stale source.
495
+ mkdirSync(globalDir, { recursive: true });
496
+ if (existsSync(newPath)) {
497
+ safeRmSync(oldPath);
498
+ }
499
+ else {
500
+ renameSync(oldPath, newPath);
501
+ }
502
+ note("totopo now keeps audio settings and the PulseAudio cookie in ~/.totopo/global/.\nOn macOS it can start and stop the host audio server for you - see the totopo menu > Settings > Voice / audio.", "Voice / audio");
503
+ }
456
504
  /**
457
505
  * v3.2.1 and earlier: Remove deprecated fields from totopo.yaml.
458
506
  * - schema_version: redundant, totopo validates with the bundled JSON schema at runtime
@@ -526,6 +574,11 @@ function buildMigrations(cwd, skipAnyConfirmations) {
526
574
  },
527
575
  { from: "v3.4.0", description: "Add git_mode=local to .lock files (preserves pre-v3.4.0 behavior)", run: migrateAddGitMode },
528
576
  { from: "v3.9.0", description: "Add audio=false to .lock files (preserves pre-v3.9.0 behavior)", run: migrateAddAudio },
577
+ {
578
+ from: "v3.10.0",
579
+ description: "Move pulse cookie to ~/.totopo/global/",
580
+ run: () => migrateMoveAudioCookie(!skipAnyConfirmations),
581
+ },
529
582
  ];
530
583
  }
531
584
  /** Run all migrations in order. Called early in bin/totopo.js startup. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.9.0",
3
+ "version": "3.10.0-rc-1",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,4 +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.
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 **Settings -> Voice / audio** — not from inside the container.
@@ -281,7 +281,7 @@ function firstNonFlag(args) {
281
281
  function blocked(label) {
282
282
  return {
283
283
  allow: false,
284
- reason: `git: '${label}' blocked in strict mode (read-only). Switch git mode via 'totopo' menu > Manage Workspace > Git mode.`,
284
+ reason: `git: '${label}' blocked in strict mode (read-only). Switch git mode via 'totopo' menu > Settings > Git mode.`,
285
285
  };
286
286
  }
287
287