agentcage 0.1.5 → 0.1.7

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
@@ -87,7 +87,7 @@ agentcage upgrade <name> # rebuild with the Desktop app's OpenCode versio
87
87
  agentcage stop <name>
88
88
  agentcage remove <name> # delete container, volumes and config
89
89
  agentcage version # installed version and the latest on npm
90
- agentcage settings # project color and name prefixes for new containers
90
+ agentcage settings # project color, name prefixes, clean up dead containers
91
91
  agentcage doctor
92
92
  agentcage adopt <name> # import a container made by the old bash scripts in legacy/
93
93
  ```
@@ -106,10 +106,15 @@ created afterwards.
106
106
  | Docker container prefix | `ac-` | container and volume names, e.g. `ac-myproject`, `ac-myproject-src` |
107
107
  | OpenCode server prefix | `ac-` | the server's name in OpenCode Desktop |
108
108
  | OpenCode project prefix | `ac-` | the project's name in OpenCode (the folder stays `/workspaces/<name>`) |
109
+ | Git token per project | `don't use` | `ask every time` asks token or forwarded credentials for each new container, `always use` always asks for a token. See [Git push access](#git-push-access) |
110
+
111
+ **Clean up dead containers** (also in Settings) lists leftovers of containers that no longer exist in Docker: agentcage
112
+ configs and OpenCode Desktop servers. Stopped containers are kept. A config whose volumes still hold code is not
113
+ preselected, because removing it deletes those volumes.
109
114
 
110
115
  ## Git push access
111
116
 
112
- The copy in the container is separate from your laptop's, so changes leave the container via **git push**. The dialog offers two ways:
117
+ The copy in the container is separate from your laptop's, so changes leave the container via **git push**. There are two ways. By default no token is used and the dialog doesn't ask; the **Git token per project** setting changes that:
113
118
 
114
119
  - **A token for this repo only (recommended).** The dialog shows how to create one: a fine-grained GitHub token limited to
115
120
  the repo with *Contents: read and write*, a GitLab project access token, or a Bitbucket repository access token. The token
@@ -131,6 +136,12 @@ agentcage reads the Desktop app's version (from `opencode-cli.version` in its in
131
136
  version in the container, because Desktop only talks to matching servers. `list`, `start` and `doctor` warn when the app has
132
137
  updated since; `agentcage upgrade <name>` rebuilds the container with the new version and keeps your code, logins and sessions.
133
138
 
139
+ The OpenCode server is the container's main process, so it runs after every start: `agentcage start`, Docker Desktop, or an
140
+ IDE starting the container. Containers made by older agentcage versions get this after one `agentcage upgrade <name>`.
141
+
142
+ Cursor and VS Code open `/workspaces/<name>` however you attach (agentcage, Remote Explorer, Docker extension, recent list):
143
+ agentcage sets the folder in the IDE's attached-container config for the container.
144
+
134
145
  Desktop has no API or deep link for adding a server, so agentcage writes the app's own state DB (`drafts.sqlite` in the
135
146
  app's data folder, backed up as `drafts.sqlite.agentcage-backup`). **The app must be closed for this.** The dialog asks you
136
147
  to close it. This relies on the app's internal storage format (written against Desktop 2.0.23), so a future app update may
package/bin/agentcage.js CHANGED
@@ -23,7 +23,7 @@ const HELP = `agentcage: isolated dev containers for AI coding agents
23
23
  agentcage provider <name> add model provider API keys to OpenCode in the container
24
24
  agentcage upgrade <name> rebuild with the Desktop app's OpenCode version (volumes kept)
25
25
  agentcage remove <name> delete container, volumes and config
26
- agentcage settings project color and name prefixes for new containers
26
+ agentcage settings project color, name prefixes, clean up dead containers
27
27
  agentcage version | -v installed version, and the latest on npm (update: npm i -g agentcage@latest)
28
28
  agentcage doctor check Docker, Node and OpenCode Desktop
29
29
  agentcage adopt <name> import a container made by the old devbox bash scripts
@@ -85,6 +85,7 @@ const commands = {
85
85
  b.opencodeVersion = v; writeDevcontainer(b);
86
86
  await up(b, true).then(() => s.stop(`Rebuilt with OpenCode ${v}`), e => { s.error('Rebuild failed'); throw e; });
87
87
  saveContainer(b);
88
+ if (b.tools.includes('opencode')) await setupProject(b).catch(e => p.log.warn(`Could not set the project name and icon in OpenCode: ${e.message}`));
88
89
  },
89
90
  async remove() {
90
91
  const b = container();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcage",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Isolated dev containers for AI coding agents (OpenCode, Claude Code), connected to OpenCode Desktop",
5
5
  "type": "module",
6
6
  "bin": {
package/src/container.js CHANGED
@@ -41,9 +41,14 @@ export function nameTaken(n) {
41
41
  const bindable = port => new Promise(r => {
42
42
  const s = createServer().once('error', () => r(false)).listen(port, '127.0.0.1', () => s.close(() => r(true)));
43
43
  });
44
+ // Host ports published by any Docker container, running or stopped (docker ps shows none for stopped ones)
45
+ export function publishedPorts() {
46
+ const ids = runSync('docker', ['ps', '-aq']).split('\n').filter(Boolean);
47
+ const out = ids.length ? runSync('docker', ['inspect', '-f', '{{json .HostConfig.PortBindings}}', ...ids]) : '';
48
+ return new Set([...out.matchAll(/"HostPort":"(\d+)"/g)].map(m => +m[1]));
49
+ }
44
50
  async function freePort() {
45
- const used = new Set(listContainers().map(b => b.port));
46
- for (const m of runSync('docker', ['ps', '-a', '--format', '{{.Ports}}']).matchAll(/:(\d+)->/g)) used.add(+m[1]);
51
+ const used = new Set([...listContainers().map(b => b.port), ...publishedPorts()]);
47
52
  for (let p = 4100; ; p++) if (!used.has(p) && (await bindable(p))) return p;
48
53
  }
49
54
 
@@ -60,18 +65,21 @@ export function writeDevcontainer(b) {
60
65
  workspaceFolder: ws(b),
61
66
  mounts: [`source=${b.volumes.home},target=/home/node,type=volume`],
62
67
  runArgs: [`--name=${b.container}`, `--label=agentcage.name=${b.name}`, ...(oc ? ['-p', `127.0.0.1:${b.port}:4096`] : [])],
63
- containerEnv: oc ? { OPENCODE_SERVER_PASSWORD: b.password } : {},
68
+ containerEnv: { AGENTCAGE_WORKSPACE: ws(b), ...(oc && { OPENCODE_SERVER_PASSWORD: b.password }) },
64
69
  features: Object.fromEntries(tools.filter(t => t.feature).map(t => [t.feature, {}])),
65
70
  remoteUser: 'node',
66
71
  init: true,
72
+ overrideCommand: false, // run the image's CMD (agentcage-start) on every start, not only on devcontainer up
67
73
  forwardPorts: [3000],
68
74
  postCreateCommand: 'agentcage-post-create',
69
- ...(oc && { postStartCommand: 'setsid nohup opencode serve --hostname 0.0.0.0 --port 4096 > /tmp/opencode-serve.log 2>&1 &' }),
70
75
  };
71
76
  writeFileSync(join(dir, 'devcontainer.json'), JSON.stringify(cfg, null, 2) + '\n');
72
77
  }
73
78
 
74
- export const up = (b, rebuild) => devcontainer(['up', '--workspace-folder', b.dir, ...(rebuild ? ['--remove-existing-container'] : [])]);
79
+ export async function up(b, rebuild) {
80
+ await devcontainer(['up', '--workspace-folder', b.dir, ...(rebuild ? ['--remove-existing-container'] : [])]);
81
+ pinIdeFolder(b);
82
+ }
75
83
  export const state = b => runSync('docker', ['inspect', '-f', '{{.State.Status}}', b.container]) || 'missing';
76
84
 
77
85
  // o: { src, name, tools, git: 'token'|'forward'|'none', token, opencodeVersion }. step(label, fn) shows progress.
@@ -100,10 +108,35 @@ export async function removeContainer(b) {
100
108
  for (const v of Object.values(b.volumes)) await run('docker', ['volume', 'rm', v]).catch(() => {});
101
109
  rmSync(b.dir, { recursive: true, force: true });
102
110
  rmSync(join(ROOT, b.name), { recursive: true, force: true });
111
+ for (const d of ideConfigDirs()) rmSync(join(d, 'nameConfigs', `${b.container}.json`), { force: true });
112
+ }
113
+
114
+ // VS Code-based IDEs keep an attached-container config per container name (nameConfigs) and per image (imageConfigs).
115
+ // Its workspaceFolder decides the folder that opens on any attach (Remote Explorer, Docker extension, recent list),
116
+ // so point both at the code. Other keys in those files (e.g. extensions) are kept.
117
+ const CONFIG_HOME = P => P === 'win32' ? process.env.APPDATA ?? '' : P === 'darwin' ? join(homedir(), 'Library', 'Application Support')
118
+ : process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
119
+ const ideConfigDirs = (root = CONFIG_HOME(process.platform)) => (existsSync(root) ? readdirSync(root) : [])
120
+ .map(app => join(root, app, 'User', 'globalStorage')).filter(existsSync)
121
+ .flatMap(gs => readdirSync(gs).filter(d => d.endsWith('remote-containers')).map(d => join(gs, d)));
122
+ export function pinIdeFolder(b) {
123
+ const image = runSync('docker', ['inspect', '-f', '{{.Config.Image}}', b.container]);
124
+ for (const d of ideConfigDirs()) {
125
+ // image configs: only fix existing ones, they are shared by every container of that image
126
+ for (const f of [join(d, 'nameConfigs', `${b.container}.json`), ...(image ? [join(d, 'imageConfigs', `${image}.json`)].filter(existsSync) : [])]) {
127
+ try {
128
+ const cfg = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : {};
129
+ if (cfg.workspaceFolder === ws(b)) continue;
130
+ mkdirSync(join(f, '..'), { recursive: true });
131
+ writeFileSync(f, JSON.stringify({ ...cfg, workspaceFolder: ws(b) }, null, '\t'));
132
+ } catch {} // not plain JSON (hand-edited with comments): leave it alone
133
+ }
134
+ }
103
135
  }
104
136
 
105
137
  // Opens a VS Code-based IDE attached to the running container
106
138
  export function openIde(b, ide) {
139
+ pinIdeFolder(b);
107
140
  const hex = Buffer.from(JSON.stringify({ containerName: `/${b.container}` })).toString('hex');
108
141
  launch(ide, ['--folder-uri', `vscode-remote://attached-container+${hex}${ws(b)}`]);
109
142
  }
package/src/icon.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // Generates the 128x128 project icon for OpenCode: dark green background, white text in two rows —
2
- // the project prefix ("ac") in small letters on top, the first three letters of the container name below.
2
+ // the project prefix ("ac") in small letters on top, the first three letters of the container name in capitals below —
3
+ // and a light color band along the bottom, its hue picked from the name (looks random, stays the same per project).
3
4
  // A hand-rolled PNG encoder (zlib + CRC) with a 5x7 bitmap font keeps this dependency-free.
4
5
  import { deflateSync } from 'node:zlib';
5
6
 
6
7
  const SIZE = 128, SS = 4, BG = [20, 83, 45], FG = [255, 255, 255]; // SS: supersampling for smooth edges
7
8
 
8
- // 5x7 pixel font ('#' = ink); lowercase letters, digits and - cover all names and prefixes
9
+ const BAND = 4; // bottom pixel rows in the accent color
10
+
11
+ // 5x7 pixel font ('#' = ink); lowercase and capital letters, digits and - cover all names and prefixes
9
12
  const FONT = {
10
13
  a: ['.....', '.....', '.###.', '....#', '.####', '#...#', '.####'],
11
14
  b: ['#....', '#....', '####.', '#...#', '#...#', '#...#', '####.'],
@@ -44,6 +47,39 @@ const FONT = {
44
47
  8: ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
45
48
  9: ['.###.', '#...#', '#...#', '.####', '....#', '....#', '.###.'],
46
49
  '-': ['.....', '.....', '.....', '.###.', '.....', '.....', '.....'],
50
+ A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
51
+ B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
52
+ C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
53
+ D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
54
+ E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
55
+ F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
56
+ G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.####'],
57
+ H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
58
+ I: ['.###.', '..#..', '..#..', '..#..', '..#..', '..#..', '.###.'],
59
+ J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'],
60
+ K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
61
+ L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
62
+ M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
63
+ N: ['#...#', '#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#'],
64
+ O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
65
+ P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
66
+ Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
67
+ R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
68
+ S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
69
+ T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
70
+ U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
71
+ V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
72
+ W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '#.#.#', '.#.#.'],
73
+ X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
74
+ Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
75
+ Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
76
+ };
77
+
78
+ // Light accent color with a hue from the name, HSL(hue, 75%, 72%). Greens (hue 80-160) are skipped: no contrast with the background
79
+ const accent = name => {
80
+ const h = (160 + [...name].reduce((n, ch) => (n * 31 + ch.charCodeAt(0)) >>> 0, 7) % 280) % 360, s = 0.75, l = 0.72;
81
+ const f = k => { const a = s * Math.min(l, 1 - l), t = (k + h / 30) % 12; return Math.round(255 * (l - a * Math.max(-1, Math.min(t - 3, 9 - t, 1)))); };
82
+ return [f(0), f(8), f(4)];
47
83
  };
48
84
 
49
85
  const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; return c; });
@@ -76,10 +112,11 @@ const draw = (img, dim, text, s, y) => {
76
112
  // The icon as a data URL for OpenCode's project icon override
77
113
  export const projectIcon = (name, prefix = 'ac') => {
78
114
  const dim = SIZE * SS, img = Buffer.alloc(dim * dim * 3);
79
- for (let i = 0; i < dim * dim; i++) { img[i * 3] = BG[0]; img[i * 3 + 1] = BG[1]; img[i * 3 + 2] = BG[2]; }
80
- const rows = [[prefix.replace(/[^a-z0-9]/g, ''), 3], [name.slice(0, 3), 6]].filter(([t]) => t);
115
+ const band = accent(name);
116
+ for (let i = 0; i < dim * dim; i++) { const c = i >= dim * (SIZE - BAND) * SS ? band : BG; img[i * 3] = c[0]; img[i * 3 + 1] = c[1]; img[i * 3 + 2] = c[2]; }
117
+ const rows = [[prefix.replace(/[^a-z0-9]/g, ''), 3], [name.slice(0, 3).toUpperCase(), 6]].filter(([t]) => t);
81
118
  const gap = 12, total = rows.reduce((n, [, s]) => n + 7 * s, 0) + gap * (rows.length - 1);
82
- let y = Math.round((SIZE - total) / 2);
119
+ let y = Math.round((SIZE - BAND - total) / 2) - 4; // centered above the band, then nudged up
83
120
  for (const [t, s] of rows) { draw(img, dim, t, s, y); y += 7 * s + gap; }
84
121
  const out = Buffer.alloc(SIZE * SIZE * 4); // downsample SS x SS blocks for smooth edges
85
122
  for (let py = 0; py < SIZE; py++) for (let px = 0; px < SIZE; px++) {
package/src/opencode.js CHANGED
@@ -34,25 +34,50 @@ export const desktopRunning = () => P === 'win32'
34
34
 
35
35
  export const latestVersion = async () => (await (await fetch('https://registry.npmjs.org/@opencode/cli/latest')).json()).version;
36
36
 
37
- // Desktop has no API or deep link for adding a server, so write its state DB (app must be closed). A backup is kept.
38
- export async function connectDesktop(container) {
37
+ // Desktop has no API or deep link for adding a server, so edit its state DB (app must be closed). A backup is kept.
38
+ const urlOf = x => (typeof x === 'string' ? x : x.http?.url ?? x.url);
39
+ async function serverState(edit) {
39
40
  const { db, hasDb } = detectDesktop();
40
41
  if (!hasDb) throw new Error(`OpenCode Desktop state not found at ${db}. Open the app once first.`);
41
- if (desktopRunning()) throw new Error('OpenCode Desktop is running. Close it first.');
42
- copyFileSync(db, db + '.agentcage-backup');
42
+ if (edit && desktopRunning()) throw new Error('OpenCode Desktop is running. Close it first.');
43
43
  const { DatabaseSync } = await import('node:sqlite');
44
- const d = new DatabaseSync(db), url = `http://127.0.0.1:${container.port}`, dir = `/workspaces/${container.name}`;
44
+ const d = new DatabaseSync(db, { readOnly: !edit });
45
45
  const row = d.prepare("select value from state where name='opencode.global.dat' and key='server'").get();
46
46
  const s = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {}, ...(row && JSON.parse(row.value)) };
47
- const urlOf = x => (typeof x === 'string' ? x : x.http?.url ?? x.url);
48
- s.list = s.list.filter(x => urlOf(x) !== url).concat({ type: 'http', http: { url, password: container.password }, displayName: serverName(container) });
49
- s.projects[url] = [{ worktree: dir, expanded: true }];
50
- s.lastProject[url] = dir;
51
- d.prepare(`insert into state(name, key, value, updated_at) values ('opencode.global.dat', 'server', ?, ?)
52
- on conflict(name, key) do update set value = excluded.value, updated_at = excluded.updated_at`).run(JSON.stringify(s), Date.now());
47
+ if (edit) {
48
+ copyFileSync(db, db + '.agentcage-backup');
49
+ edit(s, d);
50
+ d.prepare(`insert into state(name, key, value, updated_at) values ('opencode.global.dat', 'server', ?, ?)
51
+ on conflict(name, key) do update set value = excluded.value, updated_at = excluded.updated_at`).run(JSON.stringify(s), Date.now());
52
+ }
53
53
  d.close();
54
+ return s;
54
55
  }
55
56
 
57
+ export const connectDesktop = c => serverState(s => {
58
+ const url = `http://127.0.0.1:${c.port}`, dir = `/workspaces/${c.name}`;
59
+ s.list = s.list.filter(x => urlOf(x) !== url).concat({ type: 'http', http: { url, password: c.password }, displayName: serverName(c) });
60
+ s.projects[url] = [{ worktree: dir, expanded: true }];
61
+ s.lastProject[url] = dir;
62
+ });
63
+
64
+ // Servers on this machine (127.0.0.1:<port>) registered in Desktop: [{ url, port, name }]
65
+ export const desktopServers = async () => (await serverState()).list.map(x => ({ url: urlOf(x), name: x.displayName ?? urlOf(x) }))
66
+ .map(x => ({ ...x, port: +x.url?.match(/^https?:\/\/(?:127\.0\.0\.1|localhost):(\d+)/)?.[1] })).filter(x => x.port);
67
+
68
+ // Removes servers with everything Desktop keeps per server URL (UI history such as tabs is left to the app)
69
+ export const removeDesktopServers = urls => serverState((s, d) => {
70
+ s.list = s.list.filter(x => !urls.includes(urlOf(x)));
71
+ for (const k of ['hidden', 'projects', 'lastProject', 'recentlyClosed']) for (const u of urls) delete s[k][u];
72
+ const del = d.prepare("delete from state where name='opencode.global.dat' and key=?");
73
+ for (const u of urls) del.run(u);
74
+ const row = d.prepare("select value from state where name='opencode.global.dat' and key='home.servers'").get(), h = row && JSON.parse(row.value);
75
+ if (h?.collapsed) {
76
+ for (const u of urls) delete h.collapsed[u];
77
+ d.prepare("update state set value=? where name='opencode.global.dat' and key='home.servers'").run(JSON.stringify(h));
78
+ }
79
+ });
80
+
56
81
  // Names stored at creation; containers made before the settings existed use the current settings
57
82
  export const serverName = c => c.serverName ?? loadSettings().serverPrefix + c.name;
58
83
 
package/src/settings.js CHANGED
@@ -6,10 +6,16 @@ import { join } from 'node:path';
6
6
  const FILE = join(homedir(), '.agentcage', 'settings.json');
7
7
  // Project icon colors OpenCode Desktop can show (others render as gray). green first: our default, gray: OpenCode's.
8
8
  export const COLORS = ['green', 'gray', 'pink', 'blue', 'red', 'purple', 'yellow', 'orange', 'cyan'];
9
- export const DEFAULTS = { color: 'green', containerPrefix: 'ac-', serverPrefix: 'ac-', projectPrefix: 'ac-' };
9
+ export const DEFAULTS = { color: 'green', containerPrefix: 'ac-', serverPrefix: 'ac-', projectPrefix: 'ac-', gitToken: 'never' };
10
10
  export const LABELS = {
11
11
  color: 'OpenCode project color', containerPrefix: 'Docker container prefix',
12
12
  serverPrefix: 'OpenCode server prefix', projectPrefix: 'OpenCode project prefix',
13
+ gitToken: 'Git token per project (GitHub, GitLab, Bitbucket)',
14
+ };
15
+ // Settings with fixed choices ({ value: label }); the others are free-text prefixes
16
+ export const CHOICES = {
17
+ color: Object.fromEntries(COLORS.map(c => [c, c === 'gray' ? "gray (OpenCode's default)" : c])),
18
+ gitToken: { never: "don't use (push with the IDE's forwarded credentials)", ask: 'ask every time', always: 'always use' },
13
19
  };
14
20
 
15
21
  export const loadSettings = () => ({ ...DEFAULTS, ...(existsSync(FILE) && JSON.parse(readFileSync(FILE, 'utf8'))) });
package/src/wizard.js CHANGED
@@ -3,11 +3,11 @@ import * as p from '@clack/prompts';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { AGENTS, DEV_TOOLS, PROVIDERS, hostInfo } from './catalog.js';
6
- import { cleanName, createContainer, nameTaken, openIde, ws } from './container.js';
6
+ import { cleanName, createContainer, listContainers, nameTaken, openIde, publishedPorts, removeContainer, state, ws } from './container.js';
7
7
  import { isGitUrl, localRemote, parseRemote } from './git.js';
8
- import { connectDesktop, desktopRunning, detectDesktop, latestVersion, loginProvider, serverName, setupProject } from './opencode.js';
8
+ import { connectDesktop, desktopRunning, desktopServers, detectDesktop, latestVersion, loginProvider, removeDesktopServers, serverName, setupProject } from './opencode.js';
9
9
  import { ok, runSync } from './run.js';
10
- import { COLORS, DEFAULTS, LABELS, loadSettings, saveSettings } from './settings.js';
10
+ import { CHOICES, DEFAULTS, LABELS, loadSettings, saveSettings } from './settings.js';
11
11
 
12
12
  export const ask = async q => {
13
13
  const v = await q;
@@ -46,15 +46,39 @@ async function waitForDocker() {
46
46
  // Registers the container in OpenCode Desktop, asking the user to close the app if needed
47
47
  export async function registerFlow(b, interactive = true) {
48
48
  if (!detectDesktop().hasDb) return p.note(connectionInfo(b), 'OpenCode Desktop not found. Add this server by hand');
49
- while (desktopRunning()) {
50
- if (!interactive) return p.log.warn(`OpenCode Desktop is open. Close it, then run: agentcage connect ${b.name}`);
51
- const go = await ask(p.confirm({ message: 'Close OpenCode Desktop so the container can be added to it.', active: 'It is closed now', inactive: 'Skip for now' }));
52
- if (!go) return p.log.info(`Later: agentcage connect ${b.name}`);
53
- }
49
+ if (!interactive && desktopRunning()) return p.log.warn(`OpenCode Desktop is open. Close it, then run: agentcage connect ${b.name}`);
50
+ if (!(await desktopClosed('Close OpenCode Desktop so the container can be added to it.'))) return p.log.info(`Later: agentcage connect ${b.name}`);
54
51
  await connectDesktop(b);
55
52
  p.log.success(`OpenCode Desktop: added server "${serverName(b)}" with project ${ws(b)}`);
56
53
  }
57
54
 
55
+ // Desktop's state can only be written while the app is closed. Returns false if the user skips.
56
+ async function desktopClosed(message) {
57
+ while (desktopRunning()) if (!(await ask(p.confirm({ message, active: 'It is closed now', inactive: 'Skip for now' })))) return false;
58
+ return true;
59
+ }
60
+
61
+ // Leftovers of containers that no longer exist in Docker: agentcage configs (with their volumes) and OpenCode Desktop servers.
62
+ // Stopped containers still exist and are kept. Configs whose volumes still hold code are not preselected.
63
+ export async function cleanupFlow() {
64
+ const ports = publishedPorts(), dead = listContainers().filter(b => state(b) === 'missing');
65
+ const servers = detectDesktop().hasDb ? (await desktopServers()).filter(x => !ports.has(x.port)) : [];
66
+ const hasCode = b => ok('docker', ['volume', 'inspect', b.volumes.src]);
67
+ const options = [
68
+ ...dead.map(b => ({ value: `c:${b.name}`, label: `agentcage config: ${b.name}`, hint: hasCode(b) ? 'also deletes its volumes and the code in them' : 'no volumes left' })),
69
+ ...servers.map(x => ({ value: `s:${x.url}`, label: `OpenCode Desktop server: ${x.name}`, hint: x.url })),
70
+ ];
71
+ if (!options.length) return p.log.success('Nothing to clean up: every agentcage config and OpenCode server has a Docker container.');
72
+ const picked = await ask(p.multiselect({ message: 'Remove leftovers of containers that no longer exist?', options, required: false,
73
+ initialValues: options.filter(o => !dead.some(b => o.value === `c:${b.name}` && hasCode(b))).map(o => o.value) }));
74
+ for (const b of dead.filter(b => picked.includes(`c:${b.name}`))) { await removeContainer(b); p.log.success(`Removed ${b.name}`); }
75
+ const urls = servers.filter(x => picked.includes(`s:${x.url}`)).map(x => x.url);
76
+ if (!urls.length) return;
77
+ if (!(await desktopClosed('Close OpenCode Desktop so its servers can be removed.'))) return p.log.info('OpenCode Desktop servers were not removed.');
78
+ await removeDesktopServers(urls);
79
+ p.log.success(`Removed ${urls.length} server${urls.length > 1 ? 's' : ''} from OpenCode Desktop`);
80
+ }
81
+
58
82
  // Hands the terminal to OpenCode's own key prompt for each provider
59
83
  export async function providerFlow(b, ids) {
60
84
  ids ??= await ask(p.multiselect({ message: 'Which providers should OpenCode use in this container?', options: PROVIDERS.map(x => ({ value: x.id, label: x.label, hint: x.hint })) }));
@@ -88,13 +112,17 @@ export async function newWizard(f = {}) {
88
112
  const remoteUrl = isGitUrl(src) ? src : localRemote(src), remote = parseRemote(remoteUrl);
89
113
  let git = 'none', token;
90
114
  if (remote) {
91
- git = await pick(f.git, 'forward', () => ask(p.select({ message: `How should the container push to ${remote.host}/${remote.path}?`, options: [
115
+ // The gitToken setting decides whether to ask (never / always skip the question); --git overrides it
116
+ const fixed = f.git ?? { never: 'forward', always: 'token' }[s.gitToken];
117
+ git = await pick(fixed, 'forward', () => ask(p.select({ message: `How should the container push to ${remote.host}/${remote.path}?`, options: [
92
118
  { value: 'token', label: 'A token for this repo only', hint: 'recommended: agents can only push here' },
93
119
  { value: 'forward', label: "Skip: use my IDE's forwarded credentials", hint: 'access to all your repos while the IDE is attached' },
94
120
  ] })));
95
121
  if (git === 'token') {
96
122
  p.note(hostInfo(remote.host).help(remote.path), 'Repo token');
97
123
  token = process.env.AGENTCAGE_GIT_TOKEN ?? await ask(p.password({ message: 'Paste the token', validate: v => (v ? undefined : 'Required') }));
124
+ } else if (fixed && !f.git) {
125
+ p.log.info(`Git push: your IDE's forwarded credentials, no repo token (change in: agentcage settings)`);
98
126
  } else if (!f.yes) {
99
127
  p.note(FORWARD_WARNING, 'Warning: cross-repo access');
100
128
  if (!(await ask(p.confirm({ message: 'Continue with forwarded credentials?', initialValue: false })))) return newWizard({ ...f, src, name });
@@ -152,10 +180,12 @@ export async function settingsWizard() {
152
180
  const s = loadSettings();
153
181
  for (;;) {
154
182
  const key = await ask(p.select({ message: 'Which setting? (applies to new containers)', options: [
155
- ...Object.keys(DEFAULTS).map(k => ({ value: k, label: LABELS[k], hint: s[k] === '' ? 'none' : s[k] })), { value: '', label: 'Done' }] }));
183
+ ...Object.keys(DEFAULTS).map(k => ({ value: k, label: LABELS[k], hint: s[k] === '' ? 'none' : CHOICES[k]?.[s[k]] ?? s[k] })),
184
+ { value: '-cleanup', label: 'Clean up dead containers', hint: 'configs and OpenCode servers whose container is gone' }, { value: '', label: 'Done' }] }));
156
185
  if (!key) break;
157
- s[key] = await ask(key === 'color'
158
- ? p.select({ message: LABELS.color, initialValue: s.color, options: COLORS.map(c => ({ value: c, label: c, hint: c === 'gray' ? "OpenCode's default" : undefined })) })
186
+ if (key === '-cleanup') { await cleanupFlow(); continue; }
187
+ s[key] = await ask(CHOICES[key]
188
+ ? p.select({ message: LABELS[key], initialValue: s[key], options: Object.entries(CHOICES[key]).map(([value, label]) => ({ value, label })) })
159
189
  : p.text({ message: `${LABELS[key]} (empty for none)`, initialValue: s[key], defaultValue: '', validate: v => (/^[a-z0-9-]*$/.test(v ?? '') ? undefined : 'Use lowercase letters, digits and -') }));
160
190
  saveSettings(s);
161
191
  }
@@ -12,3 +12,8 @@ RUN if [ -n "$OPENCODE_VERSION" ]; then npm install -g @opencode/cli@${OPENCODE_
12
12
 
13
13
  COPY post-create.sh /usr/local/bin/agentcage-post-create
14
14
  RUN chmod +x /usr/local/bin/agentcage-post-create
15
+
16
+ # Main process: starts the OpenCode server on every container start (devcontainer.json sets overrideCommand: false)
17
+ COPY start.sh /usr/local/bin/agentcage-start
18
+ RUN chmod +x /usr/local/bin/agentcage-start
19
+ CMD ["agentcage-start"]
@@ -0,0 +1,12 @@
1
+ #!/bin/sh
2
+ # Container main process. Keeps the OpenCode server running as the node user, from every container start
3
+ # (docker start, Docker Desktop restart, an IDE starting it), and restarts it if it exits.
4
+ if [ -n "$OPENCODE_SERVER_PASSWORD" ]; then
5
+ cd "${AGENTCAGE_WORKSPACE:-/tmp}"
6
+ while :; do
7
+ # node opens the log: root may not write a node-owned file in /tmp (fs.protected_regular)
8
+ runuser -u node -- sh -c 'exec opencode serve --hostname 0.0.0.0 --port 4096 >> /tmp/opencode-serve.log 2>&1'
9
+ sleep 2
10
+ done &
11
+ fi
12
+ exec sleep infinity