totopo 2.0.0 → 2.1.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 CHANGED
@@ -25,7 +25,7 @@ totopo organises work around **projects** — any local directory you register w
25
25
  - **Runtime Mode** — adjust runtime mode and installed tools
26
26
  - **Rebuild container** — rebuild the docker image (upon changing runtime mode)
27
27
 
28
- All config lives in `~/.totopo/` — nothing is written to your project by default.
28
+ All config lives in `~/.totopo/` — nothing is written to your project directory.
29
29
 
30
30
  ### Concurrent Sessions
31
31
  totopo uses one Docker container per project, not one per session. You can open as many terminal sessions as you need — they all connect to the same container, keeping resource usage bounded and reconnections fast.
@@ -53,11 +53,11 @@ npx totopo
53
53
  ## Core features at a glance
54
54
 
55
55
  - **Docker isolation** — AI agents run in a container with strict filesystem and privilege boundaries
56
- - **Agents can't reach remote** — push, pull, fetch, and clone are blocked inside the container, preventing agents from accidentally affecting your remote repositories
57
- - **AI CLIs with persistent sessions** — OpenCode, Claude Code, and Codex are pre-installed, with conversation history that survives restarts and rebuilds
58
- - **Host-mirror or full runtime** — either match the container environment to your host, or use a standard dev container with the latest stable tools
59
- - **Agents are scope-aware** — agents are informed of the mounted files and constraints at session start, so they can factor that into how they work
60
- - **Scoped access** — expose only the files and directories the agent needs
56
+ - **No remote git access** — push, pull, fetch, and clone are blocked inside the container, so agents can't accidentally affect your remote repositories
57
+ - **Scoped access** — expose only the files and directories the agent needs; agents are informed of their scope and constraints at session start
58
+ - **AI CLIs included** — OpenCode, Claude Code, and Codex are pre-installed and ready to use
59
+ - **Persistent agent memory** — conversation history and session data survive container restarts and rebuilds; if your project has its own `.claude/`, `.codex/`, or `.opencode/` directories, they pass through into the container — otherwise they are stored in `~/.totopo/`
60
+ - **Host-mirror or standard runtime** — match the container environment to your host, or use a general-purpose dev container with the latest stable tools
61
61
 
62
62
  ## Features in Detail
63
63
 
@@ -99,7 +99,7 @@ Scoped sessions are well-suited for focused tasks where you want to give the age
99
99
  <!-- Example showcasing agent awareness of selective scope limitations:-->
100
100
  <!-- ![Scoped access](.github/assets/demo-scoped.gif) -->
101
101
 
102
- ### AI CLIs with persistent sessions
102
+ ### AI CLIs included
103
103
 
104
104
  The container comes with the major AI coding CLIs ready to use out of the box:
105
105
 
@@ -109,20 +109,24 @@ claude # Claude Code (Anthropic)
109
109
  codex # Codex (OpenAI)
110
110
  ```
111
111
 
112
- Agent session data is isolated per project, so agents do not bleed context between projects. To clear memory, run `npx totopo` and navigate to `Manage totopo > Clear agent memory` and select a project. This stops the container if running and removes the agents directory.
112
+ ### Persistent agent memory
113
+
114
+ Agent session data is isolated per project and persists across container restarts and rebuilds. If your project has its own `.claude/`, `.codex/`, or `.opencode/` directories, they pass through into the container so the AI CLI can read your project-level config. If they don't exist, totopo redirects writes to `~/.totopo/` so nothing is created in your project directory.
115
+
116
+ To clear memory, run `npx totopo` and navigate to `Manage totopo > Clear agent memory` and select a project. This stops the container if running and removes the agents directory.
113
117
 
114
118
  ### Dev container runtime
115
119
 
116
120
  Choose between two modes:
117
121
 
118
122
  - **Host-mirror** — the container runtime matches your host Node.js version and selected tools, keeping the environment consistent with your local setup.
119
- - **Full** — a full dev container with the latest stable versions of all tools. Good default if you do not need version parity with your host.
123
+ - **Standard** — a general-purpose dev container with the latest stable versions of all tools. Good default if you do not need version parity with your host.
120
124
 
121
125
  Either way, basic dev tools and all three AI CLIs are always included.
122
126
 
123
127
  ## What gets installed
124
128
 
125
- All totopo config lives in `~/.totopo/` on your machine — nothing is written to your project directory unless you opt in.
129
+ All totopo config lives in `~/.totopo/` on your machine — nothing is written to your project directory.
126
130
 
127
131
  ```text
128
132
  ~/.totopo/
@@ -136,7 +140,9 @@ All totopo config lives in `~/.totopo/` on your machine — nothing is written t
136
140
  └── agents/ # agent session data — created on first session start
137
141
  ├── claude/ # mounted as ~/.claude/
138
142
  ├── opencode/ # mounted as ~/.config/opencode/ + ~/.local/share/opencode/
139
- └── codex/ # mounted as ~/.codex/
143
+ ├── codex/ # mounted as ~/.codex/
144
+ └── workspace/ # shadow mounts — used when the project doesn't have
145
+ # its own .claude/, .codex/, or .opencode/ dirs
140
146
  ```
141
147
 
142
148
  Agent session history and conversation data are persisted in the `agents/` directory across container rebuilds and restarts.
package/bin/totopo.js CHANGED
@@ -148,7 +148,7 @@ while (showMenu) {
148
148
  showMenu = true;
149
149
  break;
150
150
  case "manage-totopo": {
151
- const result = await advanced(packageDir);
151
+ const result = await advanced(packageDir, project.id);
152
152
  if (result === "back") showMenu = true;
153
153
  break;
154
154
  }
@@ -152,6 +152,51 @@ async function resetApiKeys(packageDir) {
152
152
  cpSync(join(packageDir, "templates", "env"), globalEnvPath);
153
153
  log.success(`API keys reset. Edit ${globalEnvPath} to add your keys.`);
154
154
  }
155
+ // --- Uninstall projects (multi-select, remove container + image + project dir) -----------------------------------------------------------
156
+ async function uninstallProjects(currentProjectId) {
157
+ const projects = listProjects();
158
+ if (projects.length === 0) {
159
+ log.info("No registered projects.");
160
+ return;
161
+ }
162
+ // Show current project first if known
163
+ const sorted = currentProjectId
164
+ ? [...projects].sort((a, b) => (a.id === currentProjectId ? -1 : b.id === currentProjectId ? 1 : 0))
165
+ : projects;
166
+ const selected = await multiselect({
167
+ message: "Select projects to uninstall:",
168
+ options: sorted.map((p) => ({
169
+ value: p.id,
170
+ label: p.meta.displayName,
171
+ hint: p.meta.projectRoot + (p.id === currentProjectId ? " (current)" : ""),
172
+ })),
173
+ required: false,
174
+ });
175
+ if (isCancel(selected)) {
176
+ cancel();
177
+ return;
178
+ }
179
+ for (const id of selected) {
180
+ const p = projects.find((x) => x.id === id);
181
+ if (!p)
182
+ continue;
183
+ // Stop and remove container if it exists (running or exited)
184
+ const psResult = spawnSync("docker", ["ps", "-a", "--filter", `name=${p.meta.containerName}`, "--format", "{{.Names}}"], {
185
+ encoding: "utf8",
186
+ });
187
+ const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
188
+ for (const c of containers) {
189
+ log.step(`Stopping and removing container ${c}...`);
190
+ stopAndRemoveContainer(c);
191
+ }
192
+ // Remove Docker image if it exists (image name = container name)
193
+ log.step(`Removing image ${p.meta.containerName}...`);
194
+ spawnSync("docker", ["rmi", p.meta.containerName], { stdio: "inherit" });
195
+ // Delete project directory
196
+ rmSync(p.projectDir, { recursive: true, force: true });
197
+ log.success(`Uninstalled project ${p.meta.displayName}.`);
198
+ }
199
+ }
155
200
  // --- Uninstall totopo (global - wipes ~/.totopo/ and all containers/images) --------------------------------------------------------------
156
201
  async function uninstallTotopo() {
157
202
  const confirmed = await text({
@@ -189,7 +234,7 @@ async function uninstallTotopo() {
189
234
  outro("totopo uninstalled. Re-run npx totopo to set up again.");
190
235
  }
191
236
  // --- Manage totopo menu ------------------------------------------------------------------------------------------------------------------
192
- export async function run(packageDir) {
237
+ export async function run(packageDir, currentProjectId) {
193
238
  while (true) {
194
239
  const action = await select({
195
240
  message: "Manage totopo:",
@@ -199,6 +244,7 @@ export async function run(packageDir) {
199
244
  { value: "remove-images", label: "Remove images", hint: "pick images to remove" },
200
245
  { value: "reset-keys", label: "Reset API keys", hint: "overwrites ~/.totopo/.env" },
201
246
  { value: "doctor", label: "Doctor", hint: "check Docker health" },
247
+ { value: "uninstall-project", label: "Uninstall project", hint: "pick projects to remove" },
202
248
  { value: "uninstall", label: "Uninstall totopo", hint: "wipe ~/.totopo/ and all containers/images" },
203
249
  { value: "back", label: "← Back" },
204
250
  ],
@@ -219,6 +265,9 @@ export async function run(packageDir) {
219
265
  case "reset-keys":
220
266
  await resetApiKeys(packageDir);
221
267
  break;
268
+ case "uninstall-project":
269
+ await uninstallProjects(currentProjectId);
270
+ break;
222
271
  case "doctor":
223
272
  await runDoctor(null, true);
224
273
  await sleep(500);
@@ -5,8 +5,9 @@
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
- import { dirname, join, relative } from "node:path";
8
+ import { join, relative } from "node:path";
9
9
  import { cancel, confirm, groupMultiselect, isCancel, log, multiselect, note, outro, path, select } from "@clack/prompts";
10
+ import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext, resolveShadowedDirs, } from "../lib/agent-context.js";
10
11
  // The project config dir is always mounted here inside the container (read-only)
11
12
  const TOTOPO_CONTAINER_PATH = "/home/devuser/.totopo";
12
13
  // --- Prompt: scope selection -------------------------------------------------------------------------------------------------------------
@@ -212,25 +213,11 @@ async function promptSelectivePaths(cwd) {
212
213
  }
213
214
  return result;
214
215
  }
215
- // --- Build agent mount args --------------------------------------------------------------------------------------------------------------
216
- // Creates agents/ subdirectories in the project dir on the host (lazily) and
217
- // returns volume mount args for all supported agent tools.
218
- function buildAgentMountArgs(projectDir) {
219
- const agentsDir = join(projectDir, "agents");
220
- const mounts = [
221
- { host: join(agentsDir, "claude"), container: "/home/devuser/.claude" },
222
- { host: join(agentsDir, "opencode", "config"), container: "/home/devuser/.config/opencode" },
223
- { host: join(agentsDir, "opencode", "data"), container: "/home/devuser/.local/share/opencode" },
224
- { host: join(agentsDir, "codex"), container: "/home/devuser/.codex" },
225
- ];
226
- for (const { host } of mounts)
227
- mkdirSync(host, { recursive: true });
228
- return mounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]);
229
- }
230
216
  // --- Build mount args --------------------------------------------------------------------------------------------------------------------
231
217
  // Project config dir is always explicitly mounted - it's never inside the workspace.
232
218
  function buildMountArgs(scope, workspaceDir, projectDir, cwd) {
233
- const agentMounts = buildAgentMountArgs(projectDir);
219
+ const hostWorkspaceDir = scope.mode === "repo" ? workspaceDir : cwd;
220
+ const agentMounts = buildAgentMountArgs(projectDir, hostWorkspaceDir);
234
221
  const configMount = ["-v", `${projectDir}:${TOTOPO_CONTAINER_PATH}:ro`];
235
222
  if (scope.mode === "repo") {
236
223
  return ["-v", `${workspaceDir}:/workspace`, ...configMount, ...agentMounts];
@@ -310,97 +297,24 @@ function scopesMatch(selected, existing, workspaceDir) {
310
297
  }
311
298
  return true;
312
299
  }
313
- // Assembles the agent context markdown injected into each supported agent's config dir at session start
314
- function buildAgentContextDocs(scope) {
315
- // -- Scope section --------------------------------------------------------------------------------------------------------------------
316
- let scopeSection;
317
- if (scope.mode === "repo") {
318
- scopeSection = `## Workspace scope: repo
319
-
320
- You have access to the full repository at \`/workspace\`. Some operations (git push, system-level changes) require running on the host.`;
321
- }
322
- else if (scope.mode === "cwd") {
323
- scopeSection = `## Workspace scope: cwd
324
-
325
- Workspace is scoped to one directory (\`${scope.hostCwd}\`). Files outside it are not visible to you. Commands that depend on absent files will fail.`;
326
- }
327
- else {
328
- const pathList = scope.selectedPaths.map((p) => `- \`/workspace/${p}\``).join("\n");
329
- scopeSection = `## Workspace scope: selective
330
-
331
- Workspace is selectively scoped. Only the following paths are mounted:\n\n${pathList}`;
332
- }
333
- // -- Git section ----------------------------------------------------------------------------------------------------------------------
334
- let gitSection;
335
- if (scope.mode === "repo") {
336
- gitSection = `## Git availability
337
-
338
- Git is fully available for local operations (commit, branch, log, diff, status, etc.).
339
-
340
- Remote access (push, pull, fetch, clone) is **blocked at the system level** by design — \`protocol.allow = never\` is enforced in \`/etc/gitconfig\` and cannot be overridden without root. This is a deliberate security boundary: the container has no access to remote repositories. Ask the user to run any remote git operations from the host.`;
341
- }
342
- else {
343
- gitSection = `## Git availability
344
-
345
- Git local operations are **not available** in this scope — \`.git\` is not mounted. This is intentional: mounting \`.git\` would expose the full commit history of all repository files, including those outside your current mount, defeating the security boundary of scoped access.
346
-
347
- Remote access is also **blocked container-wide** by design (\`protocol.allow = never\` in \`/etc/gitconfig\`).
348
-
349
- If git operations are needed, ask the user to run them on the host.`;
350
- }
351
- // -- Selective-only warning -----------------------------------------------------------------------------------------------------------
352
- const selectiveWarning = scope.mode === "selective"
353
- ? `\n\n## Selective scope: file creation warning
354
-
355
- Any file you create **outside your mounted paths** (e.g. at \`/\`, \`/tmp\`, or any path not listed above) will **not be visible on the host** and will be lost when the container is rebuilt.
356
-
357
- If the user asks you to create or modify a file at such a location:
358
- 1. Notify the user that the path is outside your mounted workspace.
359
- 2. Explain that files created there will not sync to the host.
360
- 3. Suggest the user run the command on the host instead, or confirm they want the file only inside the container (understanding it will be lost on rebuild).`
361
- : "";
362
- // -- Responsibilities section ---------------------------------------------------------------------------------------------------------
363
- const responsibilitiesSection = `## Your responsibilities at session start
364
-
365
- At the start of every session:
366
- - Briefly surface your current workspace scope and its limitations to the user.
367
- - Tell the user what you cannot access in this session (files, git, remotes).`;
368
- // -- Assemble per-tool - only the self-referencing path differs -----------------------------------------------------------------------
369
- function build(toolPath) {
370
- const constraintsSection = `## Constraints
371
-
372
- - Files outside mounted paths cannot be read, written, or executed.
373
- - If a command fails because of missing files, tell the user: "I have limited workspace scope — please run \`<command>\` on the host."
374
- - \`~/.totopo/\` is read-only inside the container.
375
- - This file (\`${toolPath}\`) is managed by totopo and overwritten on every session start. Do not edit it.`;
376
- return ([
377
- "# totopo Workspace Context\n\nYou are running inside a totopo dev container.\n",
378
- scopeSection,
379
- gitSection,
380
- constraintsSection,
381
- responsibilitiesSection,
382
- ].join("\n\n") +
383
- selectiveWarning +
384
- "\n");
385
- }
386
- return {
387
- claude: build("~/.claude/CLAUDE.md"),
388
- opencode: build("~/.config/opencode/AGENTS.md"),
389
- codex: build("~/.codex/AGENTS.md"),
390
- };
300
+ // --- Shadow label args -------------------------------------------------------------------------------------------------------------------
301
+ function buildShadowLabelArgs(shadowedDirs) {
302
+ return ["--label", `totopo.shadows=${shadowedDirs.sort().join(",")}`];
391
303
  }
392
- // --- Inject agent context ----------------------------------------------------------------------------------------------------------------
393
- function injectAgentContext(projectDir, docs) {
394
- const a = join(projectDir, "agents");
395
- const files = [
396
- { path: join(a, "claude", "CLAUDE.md"), content: docs.claude },
397
- { path: join(a, "opencode", "config", "AGENTS.md"), content: docs.opencode },
398
- { path: join(a, "codex", "AGENTS.md"), content: docs.codex },
399
- ];
400
- for (const { path: filePath, content } of files) {
401
- mkdirSync(dirname(filePath), { recursive: true });
402
- writeFileSync(filePath, content);
403
- }
304
+ function readContainerShadowLabel(name) {
305
+ const result = spawnSync("docker", ["inspect", "--format", '{{index .Config.Labels "totopo.shadows"}}', name], {
306
+ encoding: "utf8",
307
+ stdio: "pipe",
308
+ });
309
+ if (result.status !== 0)
310
+ return [];
311
+ const raw = result.stdout.trim();
312
+ if (!raw || raw === "<no value>")
313
+ return [];
314
+ return raw.split(",").sort();
315
+ }
316
+ function shadowsMatch(current, existing) {
317
+ return JSON.stringify([...current].sort()) === JSON.stringify([...existing].sort());
404
318
  }
405
319
  // --- Run post-start ----------------------------------------------------------------------------------------------------------------------
406
320
  function runPostStart(containerName) {
@@ -429,7 +343,7 @@ function ensureGlobalEnvFile() {
429
343
  return envFile;
430
344
  }
431
345
  // --- Run container -----------------------------------------------------------------------------------------------------------------------
432
- function runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd) {
346
+ function runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd, shadowedDirs) {
433
347
  const envFile = ensureGlobalEnvFile();
434
348
  const run = spawnSync("docker", [
435
349
  "run",
@@ -441,6 +355,7 @@ function runContainer(scope, containerName, imageName, workspaceDir, projectDir,
441
355
  envFile,
442
356
  ...buildScopeEnvArgs(scope),
443
357
  ...buildScopeLabelArgs(scope),
358
+ ...buildShadowLabelArgs(shadowedDirs),
444
359
  "--security-opt",
445
360
  "no-new-privileges:true",
446
361
  "--label",
@@ -462,6 +377,9 @@ export async function run(_packageDir, ctx) {
462
377
  const projectDir = ctx.projectDir;
463
378
  // --- Always prompt scope first -------------------------------------------------------------------------------------------------------
464
379
  const scope = await promptScope(workspaceDir, cwd);
380
+ const hostWorkspaceDir = scope.mode === "repo" ? workspaceDir : cwd;
381
+ const shadowedDirs = resolveShadowedDirs(hostWorkspaceDir);
382
+ const agentDocs = buildAgentContextDocs(scope, shadowedDirs);
465
383
  // --- Inspect container state ---------------------------------------------------------------------------------------------------------
466
384
  const inspect = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", containerName], {
467
385
  encoding: "utf8",
@@ -477,17 +395,18 @@ export async function run(_packageDir, ctx) {
477
395
  process.exit(build.status ?? 1);
478
396
  }
479
397
  log.step("Preparing agent context...");
480
- injectAgentContext(projectDir, buildAgentContextDocs(scope));
398
+ injectAgentContext(projectDir, agentDocs);
481
399
  log.step("Starting dev container...");
482
- runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd);
400
+ runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd, shadowedDirs);
483
401
  runPostStart(containerName);
484
402
  }
485
403
  else if (containerStatus === "exited") {
486
- // --- Container stopped - resume or recreate based on scope -----------------------------------------------------------------------
404
+ // --- Container stopped - resume or recreate based on scope/shadows ---------------------------------------------------------------
487
405
  const existingScope = readContainerScopeLabel(containerName);
488
- if (scopesMatch(scope, existingScope, workspaceDir)) {
406
+ const existingShadows = readContainerShadowLabel(containerName);
407
+ if (scopesMatch(scope, existingScope, workspaceDir) && shadowsMatch(shadowedDirs, existingShadows)) {
489
408
  log.step("Preparing agent context...");
490
- injectAgentContext(projectDir, buildAgentContextDocs(scope));
409
+ injectAgentContext(projectDir, agentDocs);
491
410
  log.step("Resuming dev container...");
492
411
  const start = spawnSync("docker", ["start", containerName], { stdio: "inherit" });
493
412
  if (start.status !== 0) {
@@ -498,28 +417,29 @@ export async function run(_packageDir, ctx) {
498
417
  }
499
418
  else {
500
419
  log.step("Preparing agent context...");
501
- injectAgentContext(projectDir, buildAgentContextDocs(scope));
420
+ injectAgentContext(projectDir, agentDocs);
502
421
  log.step("Recreating dev container with new scope...");
503
422
  removeContainer(containerName);
504
- runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd);
423
+ runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd, shadowedDirs);
505
424
  runPostStart(containerName);
506
425
  }
507
426
  }
508
427
  else {
509
- // --- Container running - connect directly or recreate based on scope -------------------------------------------------------------
428
+ // --- Container running - connect directly or recreate based on scope/shadows -----------------------------------------------------
510
429
  const existingScope = readContainerScopeLabel(containerName);
511
- if (!scopesMatch(scope, existingScope, workspaceDir)) {
430
+ const existingShadows = readContainerShadowLabel(containerName);
431
+ if (!scopesMatch(scope, existingScope, workspaceDir) || !shadowsMatch(shadowedDirs, existingShadows)) {
512
432
  log.step("Preparing agent context...");
513
- injectAgentContext(projectDir, buildAgentContextDocs(scope));
433
+ injectAgentContext(projectDir, agentDocs);
514
434
  log.step("Recreating dev container with new scope...");
515
435
  removeContainer(containerName);
516
- runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd);
436
+ runContainer(scope, containerName, imageName, workspaceDir, projectDir, cwd, shadowedDirs);
517
437
  runPostStart(containerName);
518
438
  }
519
439
  else {
520
- // Same scope and container already running - refresh context in place.
440
+ // Same scope/shadows and container already running - refresh context in place.
521
441
  log.step("Refreshing agent context...");
522
- injectAgentContext(projectDir, buildAgentContextDocs(scope));
442
+ injectAgentContext(projectDir, agentDocs);
523
443
  }
524
444
  // Fall through to connect
525
445
  }
@@ -0,0 +1,223 @@
1
+ // Agent mount definitions and context injection for AI CLIs running inside totopo containers.
2
+ //
3
+ // This file is the single source of truth for which directories each AI CLI reads/writes
4
+ // and how totopo intercepts them via bind mounts. If an AI CLI changes its config layout,
5
+ // this file must be updated.
6
+ //
7
+ // Verify against official docs periodically:
8
+ // Claude Code: https://docs.anthropic.com/en/docs/claude-code
9
+ // OpenCode: https://github.com/opencode-ai/opencode
10
+ // Codex: https://github.com/openai/codex
11
+ //
12
+ // Note: OpenCode also reads `.opencode.json` (a file, not a dir) at the workspace root
13
+ // if the user has one. No shadow is needed - OpenCode never auto-creates this file in
14
+ // the project directory.
15
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
16
+ import { basename, dirname, join } from "node:path";
17
+ export const AGENT_MOUNTS = [
18
+ // Home-dir mounts - user-level AI CLI state
19
+ {
20
+ agent: "claude",
21
+ kind: "home",
22
+ hostSubpath: "claude",
23
+ container: "/home/devuser/.claude",
24
+ description: "Claude Code user-level config and session data",
25
+ },
26
+ {
27
+ agent: "opencode",
28
+ kind: "home",
29
+ hostSubpath: "opencode/config",
30
+ container: "/home/devuser/.config/opencode",
31
+ description: "OpenCode user-level config",
32
+ },
33
+ {
34
+ agent: "opencode",
35
+ kind: "home",
36
+ hostSubpath: "opencode/data",
37
+ container: "/home/devuser/.local/share/opencode",
38
+ description: "OpenCode user-level data and session history",
39
+ },
40
+ {
41
+ agent: "codex",
42
+ kind: "home",
43
+ hostSubpath: "codex",
44
+ container: "/home/devuser/.codex",
45
+ description: "Codex user-level config and session data",
46
+ },
47
+ // Workspace shadow mounts - intercept project-level config dirs
48
+ {
49
+ agent: "claude",
50
+ kind: "workspace-shadow",
51
+ hostSubpath: "workspace/.claude",
52
+ container: "/workspace/.claude",
53
+ description: "Shadows project-level .claude/",
54
+ },
55
+ {
56
+ agent: "codex",
57
+ kind: "workspace-shadow",
58
+ hostSubpath: "workspace/.codex",
59
+ container: "/workspace/.codex",
60
+ description: "Shadows project-level .codex/",
61
+ },
62
+ {
63
+ agent: "opencode",
64
+ kind: "workspace-shadow",
65
+ hostSubpath: "workspace/.opencode",
66
+ container: "/workspace/.opencode",
67
+ description: "Shadows project-level .opencode/",
68
+ },
69
+ ];
70
+ // --- Shadow resolution -------------------------------------------------------------------------------------------------------------------
71
+ /**
72
+ * Returns the container paths of workspace-shadow mounts that should be applied.
73
+ * A shadow is applied when the corresponding directory does NOT exist on the host workspace.
74
+ * If it exists, the user's real dir passes through via the parent workspace mount.
75
+ */
76
+ export function resolveShadowedDirs(hostWorkspaceDir) {
77
+ return AGENT_MOUNTS.filter((m) => m.kind === "workspace-shadow")
78
+ .filter((m) => !existsSync(join(hostWorkspaceDir, basename(m.container))))
79
+ .map((m) => m.container);
80
+ }
81
+ // --- Build agent mount args --------------------------------------------------------------------------------------------------------------
82
+ /**
83
+ * Creates agents/ subdirectories in the project dir on the host (lazily) and
84
+ * returns volume mount args for all supported agent tools.
85
+ *
86
+ * Home-dir mounts are always included. Workspace-shadow mounts are only included
87
+ * for directories that don't exist on the host workspace (automatic detection).
88
+ */
89
+ export function buildAgentMountArgs(projectDir, hostWorkspaceDir) {
90
+ const agentsDir = join(projectDir, "agents");
91
+ const shadowedDirs = new Set(resolveShadowedDirs(hostWorkspaceDir));
92
+ const mounts = AGENT_MOUNTS.filter((m) => {
93
+ if (m.kind === "home")
94
+ return true;
95
+ // Only include workspace-shadow mounts for dirs that don't exist on host
96
+ return shadowedDirs.has(m.container);
97
+ }).map((m) => ({
98
+ host: join(agentsDir, m.hostSubpath),
99
+ container: m.container,
100
+ }));
101
+ for (const { host } of mounts)
102
+ mkdirSync(host, { recursive: true });
103
+ return mounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]);
104
+ }
105
+ // --- Build agent context documents -------------------------------------------------------------------------------------------------------
106
+ /**
107
+ * Assembles the agent context markdown injected into each supported agent's config dir at session start.
108
+ */
109
+ export function buildAgentContextDocs(scope, shadowedDirs) {
110
+ // -- Scope section --------------------------------------------------------------------------------------------------------------------
111
+ let scopeSection;
112
+ if (scope.mode === "repo") {
113
+ scopeSection = `## Workspace scope: repo
114
+
115
+ You have access to the full repository at \`/workspace\`. Some operations (git push, system-level changes) require running on the host.`;
116
+ }
117
+ else if (scope.mode === "cwd") {
118
+ scopeSection = `## Workspace scope: cwd
119
+
120
+ Workspace is scoped to one directory (\`${scope.hostCwd}\`). Files outside it are not visible to you. Commands that depend on absent files will fail.`;
121
+ }
122
+ else {
123
+ const pathList = scope.selectedPaths.map((p) => `- \`/workspace/${p}\``).join("\n");
124
+ scopeSection = `## Workspace scope: selective
125
+
126
+ Workspace is selectively scoped. Only the following paths are mounted:\n\n${pathList}`;
127
+ }
128
+ // -- Git section ----------------------------------------------------------------------------------------------------------------------
129
+ let gitSection;
130
+ if (scope.mode === "repo") {
131
+ gitSection = `## Git availability
132
+
133
+ Git is fully available for local operations (commit, branch, log, diff, status, etc.).
134
+
135
+ Remote access (push, pull, fetch, clone) is **blocked at the system level** by design — \`protocol.allow = never\` is enforced in \`/etc/gitconfig\` and cannot be overridden without root. This is a deliberate security boundary: the container has no access to remote repositories. Ask the user to run any remote git operations from the host.`;
136
+ }
137
+ else {
138
+ gitSection = `## Git availability
139
+
140
+ Git local operations are **not available** in this scope — \`.git\` is not mounted. This is intentional: mounting \`.git\` would expose the full commit history of all repository files, including those outside your current mount, defeating the security boundary of scoped access.
141
+
142
+ Remote access is also **blocked container-wide** by design (\`protocol.allow = never\` in \`/etc/gitconfig\`).
143
+
144
+ If git operations are needed, ask the user to run them on the host.`;
145
+ }
146
+ // -- Selective-only warning -----------------------------------------------------------------------------------------------------------
147
+ const selectiveWarning = scope.mode === "selective"
148
+ ? `\n\n## Selective scope: file creation warning
149
+
150
+ Any file you create **outside your mounted paths** (e.g. at \`/\`, \`/tmp\`, or any path not listed above) will **not be visible on the host** and will be lost when the container is rebuilt.
151
+
152
+ If the user asks you to create or modify a file at such a location:
153
+ 1. Notify the user that the path is outside your mounted workspace.
154
+ 2. Explain that files created there will not sync to the host.
155
+ 3. Suggest the user run the command on the host instead, or confirm they want the file only inside the container (understanding it will be lost on rebuild).`
156
+ : "";
157
+ // -- Workspace config isolation section ------------------------------------------------------------------------------------------------
158
+ let isolationSection = "";
159
+ if (shadowedDirs.length > 0) {
160
+ const dirList = shadowedDirs
161
+ .sort()
162
+ .map((d) => `- \`${d}/\` — redirected to totopo's isolated agent storage`)
163
+ .join("\n");
164
+ isolationSection = `\n\n## Workspace config isolation
165
+
166
+ The following workspace directories are shadow-mounted by totopo and do NOT
167
+ correspond to directories in the user's actual project:
168
+
169
+ ${dirList}
170
+
171
+ Project memory and session state at these paths is stored in \`~/.totopo/\` on
172
+ the host, not in the user's project directory. If the user asks about where
173
+ their AI CLI config or memory is stored, explain this.
174
+
175
+ Do not mention this at session start — only surface it if the user asks.`;
176
+ }
177
+ // -- Responsibilities section ---------------------------------------------------------------------------------------------------------
178
+ const responsibilitiesSection = `## Your responsibilities at session start
179
+
180
+ At the start of every session:
181
+ - Briefly surface your current workspace scope and its limitations to the user.
182
+ - Tell the user what you cannot access in this session (files, git, remotes).`;
183
+ // -- Assemble per-tool - only the self-referencing path differs -----------------------------------------------------------------------
184
+ function build(toolPath) {
185
+ const constraintsSection = `## Constraints
186
+
187
+ - Files outside mounted paths cannot be read, written, or executed.
188
+ - If a command fails because of missing files, tell the user: "I have limited workspace scope — please run \`<command>\` on the host."
189
+ - \`~/.totopo/\` is read-only inside the container.
190
+ - This file (\`${toolPath}\`) is managed by totopo and overwritten on every session start. Do not edit it.`;
191
+ return ([
192
+ "# totopo Workspace Context\n\nYou are running inside a totopo dev container.\n",
193
+ scopeSection,
194
+ gitSection,
195
+ constraintsSection,
196
+ responsibilitiesSection,
197
+ ].join("\n\n") +
198
+ selectiveWarning +
199
+ isolationSection +
200
+ "\n");
201
+ }
202
+ return {
203
+ claude: build("~/.claude/CLAUDE.md"),
204
+ opencode: build("~/.config/opencode/AGENTS.md"),
205
+ codex: build("~/.codex/AGENTS.md"),
206
+ };
207
+ }
208
+ // --- Inject agent context ----------------------------------------------------------------------------------------------------------------
209
+ /**
210
+ * Writes agent context markdown files into the project's agents/ directory.
211
+ */
212
+ export function injectAgentContext(projectDir, docs) {
213
+ const a = join(projectDir, "agents");
214
+ const files = [
215
+ { path: join(a, "claude", "CLAUDE.md"), content: docs.claude },
216
+ { path: join(a, "opencode", "config", "AGENTS.md"), content: docs.opencode },
217
+ { path: join(a, "codex", "AGENTS.md"), content: docs.codex },
218
+ ];
219
+ for (const { path: filePath, content } of files) {
220
+ mkdirSync(dirname(filePath), { recursive: true });
221
+ writeFileSync(filePath, content);
222
+ }
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "2.0.0",
3
+ "version": "2.1.0-rc-2",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {