totopo 3.9.0 → 3.10.0-rc-2
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 +11 -11
- package/bin/totopo.js +9 -9
- package/dist/commands/{global.js → advanced.js} +3 -3
- package/dist/commands/dev.js +54 -2
- package/dist/commands/doctor.js +3 -2
- package/dist/commands/menu.js +25 -8
- package/dist/commands/{workspace.js → settings.js} +25 -6
- package/dist/lib/audio-host.js +32 -3
- package/dist/lib/constants.js +10 -0
- package/dist/lib/global-config.js +63 -0
- package/dist/lib/migrate-to-latest.js +56 -3
- package/package.json +1 -1
- package/templates/context/responsibilities.md +1 -1
- package/templates/git-readonly-wrapper.mjs +1 -1
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
|
-
- **
|
|
85
|
-
- **
|
|
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 **
|
|
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 **
|
|
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 **
|
|
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` → **
|
|
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 **
|
|
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 **
|
|
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
|
|
|
@@ -279,7 +279,7 @@ Totopo makes everyday agent mistakes safer. It is not built to stop a determined
|
|
|
279
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.
|
|
280
280
|
- Edits to your working tree. The workspace is bind-mounted, so agent changes land on your real files. Commit often.
|
|
281
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)
|
|
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) for as long as it runs. Opt in only while dictating, and stop the server when done (or let automatic mode handle it on macOS); see [Voice Mode](#voice-mode-microphone).
|
|
283
283
|
|
|
284
284
|
## Disclaimer
|
|
285
285
|
|
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,
|
|
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: "
|
|
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 === "
|
|
124
|
-
await
|
|
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(
|
|
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
|
|
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 "
|
|
182
|
-
const result = await
|
|
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/
|
|
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
|
-
// ---
|
|
242
|
+
// --- Advanced menu -----------------------------------------------------------------------------------------------------------------------
|
|
243
243
|
export async function run(currentWorkspaceId) {
|
|
244
244
|
while (true) {
|
|
245
245
|
const action = await select({
|
|
246
|
-
message: "
|
|
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" },
|
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
17
|
+
export async function run(verbose) {
|
|
17
18
|
const errors = [];
|
|
18
19
|
function check(label, ok, detail) {
|
|
19
20
|
if (ok) {
|
package/dist/commands/menu.js
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
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
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
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: "
|
|
33
|
-
{ value: "
|
|
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/
|
|
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,15 +167,18 @@ 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" +
|
|
176
|
-
"
|
|
177
|
-
"still exposes your mic over a local TCP port — totopo never stops it for you, so stop it here when done.");
|
|
181
|
+
"The server exposes your mic over a local TCP port while it runs, so keep it up only while you need voice.");
|
|
178
182
|
if (!IS_MACOS) {
|
|
179
183
|
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
184
|
}
|
|
@@ -182,6 +186,11 @@ async function audioMenu(ctx) {
|
|
|
182
186
|
{ value: "toggle", label: wiring ? "Disable wiring" : "Enable wiring", hint: "PulseAudio env for this workspace's container" },
|
|
183
187
|
];
|
|
184
188
|
if (IS_MACOS) {
|
|
189
|
+
options.push({
|
|
190
|
+
value: "mode",
|
|
191
|
+
label: `Auto start/stop: ${mode === AUDIO_MODE.automatic ? "on" : "off"}`,
|
|
192
|
+
hint: "auto-start on session, stop on last exit",
|
|
193
|
+
});
|
|
185
194
|
if (!status.installed)
|
|
186
195
|
options.push({ value: "install", label: "Install pulseaudio", hint: "via Homebrew" });
|
|
187
196
|
if (status.installed && !status.running)
|
|
@@ -202,6 +211,16 @@ async function audioMenu(ctx) {
|
|
|
202
211
|
await promptStopContainer(ctx);
|
|
203
212
|
continue;
|
|
204
213
|
}
|
|
214
|
+
if (action === "mode") {
|
|
215
|
+
// The host server is a single shared resource, so this mode is host-global. It only changes
|
|
216
|
+
// server lifecycle behavior, not container config, so no rebuild prompt.
|
|
217
|
+
const next = mode === AUDIO_MODE.automatic ? AUDIO_MODE.manual : AUDIO_MODE.automatic;
|
|
218
|
+
writeAudioMode(next);
|
|
219
|
+
log.success(next === AUDIO_MODE.automatic
|
|
220
|
+
? "Automatic mode on - opening a session starts the host audio server; exiting stops it when no other session is connected."
|
|
221
|
+
: "Automatic mode off - start and stop the host audio server yourself.");
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
205
224
|
let result;
|
|
206
225
|
if (action === "install") {
|
|
207
226
|
result = installPulse();
|
|
@@ -264,7 +283,7 @@ async function resetTotopoYaml(ctx) {
|
|
|
264
283
|
log.success("totopo.yaml reset to defaults.");
|
|
265
284
|
await promptStopContainer(ctx);
|
|
266
285
|
}
|
|
267
|
-
// ---
|
|
286
|
+
// --- Settings submenu --------------------------------------------------------------------------------------------------------------------
|
|
268
287
|
export async function run(ctx) {
|
|
269
288
|
while (true) {
|
|
270
289
|
const currentGitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
|
|
@@ -277,7 +296,7 @@ export async function run(ctx) {
|
|
|
277
296
|
{ value: "reset", label: "Reset config", hint: "restore totopo.yaml to defaults" },
|
|
278
297
|
{ value: "back", label: "← Back" },
|
|
279
298
|
];
|
|
280
|
-
const action = await select({ message: "
|
|
299
|
+
const action = await select({ message: "Settings:", options });
|
|
281
300
|
if (isCancel(action) || action === "back") {
|
|
282
301
|
return "back";
|
|
283
302
|
}
|
package/dist/lib/audio-host.js
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
package/dist/lib/constants.js
CHANGED
|
@@ -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 >
|
|
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 >
|
|
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
|
@@ -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 **
|
|
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 >
|
|
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
|
|