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 +17 -11
- package/bin/totopo.js +1 -1
- package/dist/commands/advanced.js +50 -1
- package/dist/commands/dev.js +41 -121
- package/dist/lib/agent-context.js +223 -0
- package/package.json +1 -1
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
|
|
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
|
-
- **
|
|
57
|
-
- **
|
|
58
|
-
- **
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
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
|
<!--  -->
|
|
101
101
|
|
|
102
|
-
### AI CLIs
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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);
|
package/dist/commands/dev.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
314
|
-
function
|
|
315
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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,
|
|
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
|
-
|
|
406
|
+
const existingShadows = readContainerShadowLabel(containerName);
|
|
407
|
+
if (scopesMatch(scope, existingScope, workspaceDir) && shadowsMatch(shadowedDirs, existingShadows)) {
|
|
489
408
|
log.step("Preparing agent context...");
|
|
490
|
-
injectAgentContext(projectDir,
|
|
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,
|
|
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
|
-
|
|
430
|
+
const existingShadows = readContainerShadowLabel(containerName);
|
|
431
|
+
if (!scopesMatch(scope, existingScope, workspaceDir) || !shadowsMatch(shadowedDirs, existingShadows)) {
|
|
512
432
|
log.step("Preparing agent context...");
|
|
513
|
-
injectAgentContext(projectDir,
|
|
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,
|
|
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
|
+
}
|