ai-agentmanager 1.9.0 → 1.11.0

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.
@@ -0,0 +1,189 @@
1
+ // Isolation: an agent that runs in its own Docker container instead of directly on this computer.
2
+ // It sees only its own folder (mounted at the same path, so paths, transcripts and git all line up),
3
+ // its repository's git data, its brief folder and its uploads; nothing else of yours. Claude Code
4
+ // and Codex keep their sessions in your usual folders (mounted in), so tracking, resume and usage
5
+ // work as they do on the host. Each engine gets its own config home for containers, with sign-in
6
+ // from a token (Claude: `claude setup-token`) or Codex's auth.json, read-only.
7
+ import { execFile, execFileSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { paths } from './config.js';
13
+ import { trustFolder } from './trust.js';
14
+
15
+ export const DEFAULT_IMAGE = 'ai-agentmanager-agent:1';
16
+ export const CONTAINER_ENGINES = new Set(['claude', 'codex']);
17
+ const DOCKERFILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'docker', 'agent.Dockerfile');
18
+ const homes = () => path.join(paths.home, 'containers');
19
+ const secretsFile = () => path.join(paths.home, 'secrets.json');
20
+
21
+ export const containerName = (agentId, kind = 'agent') => `agentmgr-${agentId}${kind === 'agent' ? '' : `-${kind}`}`;
22
+ export const isolationSettings = (config = {}) => ({ image: DEFAULT_IMAGE, cpus: null, memory: null, network: null, ...(config.isolation ?? {}) });
23
+
24
+ // ------------------------------------------------------------ secrets (the Claude token)
25
+
26
+ export function loadSecrets() {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(secretsFile(), 'utf8'));
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ export function saveSecrets(patch) {
35
+ const next = { ...loadSecrets(), ...patch };
36
+ for (const [k, v] of Object.entries(next)) if (v == null || v === '') delete next[k];
37
+ fs.mkdirSync(paths.home, { recursive: true });
38
+ fs.writeFileSync(secretsFile(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
39
+ return next;
40
+ }
41
+
42
+ /** The Claude Code token for containers: saved (Settings, `agentmgr sandbox token`), else CLAUDE_CODE_OAUTH_TOKEN. */
43
+ export const claudeToken = (env = process.env) => loadSecrets().claudeOauthToken || env.CLAUDE_CODE_OAUTH_TOKEN || '';
44
+
45
+ // ------------------------------------------------------------ docker
46
+
47
+ const docker = (args, opts = {}) =>
48
+ new Promise((resolve, reject) =>
49
+ execFile('docker', args, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024, ...opts }, (err, stdout, stderr) => (err ? reject(Object.assign(err, { stderr })) : resolve(String(stdout).trim()))),
50
+ );
51
+
52
+ /** Whether Docker runs here, and whether the agent image is built. */
53
+ export async function dockerStatus(config = {}) {
54
+ const { image } = isolationSettings(config);
55
+ try {
56
+ const version = await docker(['version', '--format', '{{.Server.Version}}']);
57
+ const has = await docker(['image', 'inspect', image, '--format', '{{.Id}}']).then(() => true, () => false);
58
+ return { available: true, version, image, imageBuilt: has };
59
+ } catch (err) {
60
+ return { available: false, version: null, image, imageBuilt: false, error: String(err.stderr || err.message).trim().split('\n')[0].slice(0, 200) };
61
+ }
62
+ }
63
+
64
+ let building = null;
65
+ /** Build the agent image from docker/agent.Dockerfile (a few minutes the first time). */
66
+ export function buildImage(config = {}, { log = () => {} } = {}) {
67
+ if (building) return building;
68
+ const { image } = isolationSettings(config);
69
+ building = new Promise((resolve, reject) => {
70
+ const child = execFile('docker', ['build', '-t', image, '-f', DOCKERFILE, path.dirname(DOCKERFILE)], { timeout: 30 * 60_000, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
71
+ building = null;
72
+ if (err) reject(new Error(`docker build failed: ${String(stderr || err.message).trim().split('\n').slice(-3).join(' ')}`));
73
+ else resolve({ image });
74
+ });
75
+ child.stderr?.on('data', (d) => log(String(d)));
76
+ });
77
+ return building;
78
+ }
79
+ export const isBuilding = () => Boolean(building);
80
+
81
+ /** Remove a leftover container of this name (a restart reuses the name). */
82
+ export function removeContainer(name) {
83
+ try {
84
+ execFileSync('docker', ['rm', '-f', name], { stdio: 'ignore', timeout: 15_000 });
85
+ } catch {}
86
+ }
87
+
88
+ /** Stop a running container, giving the agent time to save (it is removed when it stops). */
89
+ export function stopContainer(name, seconds = 10) {
90
+ execFile('docker', ['stop', '-t', String(seconds), name], { timeout: (seconds + 20) * 1000 }, () => {});
91
+ }
92
+
93
+ export function containerRunning(name) {
94
+ try {
95
+ return execFileSync('docker', ['inspect', '-f', '{{.State.Running}}', name], { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() === 'true';
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ // ------------------------------------------------------------ what a container sees
102
+
103
+ /** A worktree's shared git folder (its .git file points into the main repository's), or null. */
104
+ export function gitCommonDir(cwd) {
105
+ const dotgit = path.join(cwd, '.git');
106
+ try {
107
+ if (fs.statSync(dotgit).isDirectory()) return null; // a plain clone: its .git is inside the folder
108
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotgit, 'utf8'))?.[1]?.trim();
109
+ if (!gitdir) return null;
110
+ const abs = path.resolve(cwd, gitdir);
111
+ const common = fs.existsSync(path.join(abs, 'commondir')) ? path.resolve(abs, fs.readFileSync(path.join(abs, 'commondir'), 'utf8').trim()) : abs;
112
+ return fs.realpathSync(common);
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The engine's config home for containers (seeded once), with this folder trusted. Claude: a
120
+ * .claude.json with onboarding done; Codex: config.toml. Returns the host folder.
121
+ */
122
+ export function engineHome(engineId, cwd) {
123
+ const dir = path.join(homes(), engineId);
124
+ fs.mkdirSync(dir, { recursive: true });
125
+ if (engineId === 'claude') {
126
+ const file = path.join(dir, '.claude.json');
127
+ if (!fs.existsSync(file)) fs.writeFileSync(file, JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark', projects: {} }, null, 2), { mode: 0o600 });
128
+ trustFolder(cwd, file);
129
+ } else if (engineId === 'codex') {
130
+ const file = path.join(dir, 'config.toml');
131
+ const key = cwd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
132
+ const raw = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
133
+ if (!raw.includes(`[projects."${key}"]`)) fs.appendFileSync(file, `${raw && !raw.endsWith('\n') ? '\n' : ''}[projects."${key}"]\ntrust_level = "trusted"\n`);
134
+ }
135
+ return dir;
136
+ }
137
+
138
+ /**
139
+ * `docker run` arguments for an agent: the container, its mounts and limits, then the engine's
140
+ * own command (`bin` + `args`). Secrets are passed by name (`-e NAME`, the value from the docker
141
+ * command's environment), never in the arguments.
142
+ */
143
+ export function runArgs({ agentId, engineId, cwd, bin, args, config = {}, mounts = [], env = [], user = process.getuid?.() != null ? `${process.getuid()}:${process.getgid()}` : null, name = containerName(agentId) }) {
144
+ const s = isolationSettings(config);
145
+ const out = ['run', '--rm', '-i', '-t', '--init', '--name', name, '--label', `agentmgr.agent=${agentId}`, '-w', cwd];
146
+ if (user) out.push('--user', user);
147
+ if (s.cpus) out.push('--cpus', String(s.cpus));
148
+ if (s.memory) out.push('--memory', String(s.memory));
149
+ if (s.network) out.push('--network', String(s.network));
150
+ const seen = new Set();
151
+ for (const m of [{ host: cwd }, ...mounts]) {
152
+ const inside = m.inside ?? m.host;
153
+ if (!m.host || seen.has(inside)) continue;
154
+ seen.add(inside);
155
+ out.push('-v', `${m.host}:${inside}${m.readOnly ? ':ro' : ''}`);
156
+ }
157
+ for (const e of env) out.push('-e', e);
158
+ out.push(s.image, ...[bin].flat(), ...args);
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Everything an engine needs mounted and set in its container: its config home, your session
164
+ * folders (so the manager reads them where it always does) and its sign-in.
165
+ */
166
+ export function engineMounts(engineId, cwd, env = process.env) {
167
+ const home = engineHome(engineId, cwd);
168
+ if (engineId === 'claude') {
169
+ const projects = path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
170
+ fs.mkdirSync(projects, { recursive: true });
171
+ return {
172
+ mounts: [{ host: home, inside: '/agentmgr/claude' }, { host: projects, inside: '/agentmgr/claude/projects' }],
173
+ env: ['CLAUDE_CONFIG_DIR=/agentmgr/claude', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'],
174
+ bin: 'claude',
175
+ };
176
+ }
177
+ if (engineId === 'codex') {
178
+ const codexHome = env.CODEX_HOME || path.join(os.homedir(), '.codex');
179
+ const sessions = path.join(codexHome, 'sessions');
180
+ fs.mkdirSync(sessions, { recursive: true });
181
+ const auth = path.join(codexHome, 'auth.json');
182
+ return {
183
+ mounts: [{ host: home, inside: '/agentmgr/codex' }, { host: sessions, inside: '/agentmgr/codex/sessions' }, ...(fs.existsSync(auth) ? [{ host: auth, inside: '/agentmgr/codex/auth.json', readOnly: true }] : [])],
184
+ env: ['CODEX_HOME=/agentmgr/codex', 'OPENAI_API_KEY'],
185
+ bin: 'codex',
186
+ };
187
+ }
188
+ throw Object.assign(new Error(`${engineId} can't run in a container yet (Claude Code and Codex can)`), { status: 400 });
189
+ }
@@ -0,0 +1,222 @@
1
+ // Engines for agents that speak ACP (the Agent Client Protocol): Gemini CLI, OpenCode, Goose,
2
+ // Qwen Code and any other, from one line of config. AI Agent Manager runs its own terminal client
3
+ // (bin/acp-client.js) in the agent's terminal, which talks ACP to the agent and keeps a session log
4
+ // in ~/.agentmanager/acp/<engine>/<session>.jsonl; this adapter reads that log for usage, PR and
5
+ // bug links, stuck detection and Explain.
6
+ //
7
+ // config.json: "acp": { "<name>": { "command": ["opencode", "acp"], "label": "OpenCode" } }
8
+ // adds (or changes) the engine "acp-<name>". The presets below are offered when installed.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { paths } from '../config.js';
13
+ import { norm, tailOf } from '../stuck.js';
14
+ import { extractRefs } from '../tracker.js';
15
+
16
+ const CLIENT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'acp-client.js');
17
+ export const ACP_PRESETS = {
18
+ gemini: { label: 'Gemini CLI', command: ['gemini', '--experimental-acp'] },
19
+ opencode: { label: 'OpenCode', command: ['opencode', 'acp'] },
20
+ goose: { label: 'Goose', command: ['goose', 'acp'] },
21
+ qwen: { label: 'Qwen Code', command: ['qwen', '--experimental-acp'] },
22
+ };
23
+ const ANSI = /\x1b(\[[0-9;?<>=]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[()][0-9A-B]|[=>78c])/g;
24
+ const real = (p) => {
25
+ try {
26
+ return fs.realpathSync(p);
27
+ } catch {
28
+ return path.resolve(p);
29
+ }
30
+ };
31
+
32
+ /** Complete JSON lines added since the cursor's offset. */
33
+ function newRecords(file, cursor) {
34
+ let fd;
35
+ try {
36
+ fd = fs.openSync(file, 'r');
37
+ } catch {
38
+ return null;
39
+ }
40
+ try {
41
+ const { size, mtime } = fs.fstatSync(fd);
42
+ cursor.lastActivity = mtime.toISOString();
43
+ if (size < cursor.offset) cursor.offset = 0;
44
+ if (size === cursor.offset) return [];
45
+ const buf = Buffer.alloc(size - cursor.offset);
46
+ fs.readSync(fd, buf, 0, buf.length, cursor.offset);
47
+ const lastNl = buf.lastIndexOf(0x0a);
48
+ if (lastNl < 0) return [];
49
+ cursor.offset += lastNl + 1;
50
+ const out = [];
51
+ for (const line of buf.subarray(0, lastNl).toString('utf8').split('\n')) {
52
+ try {
53
+ if (line) out.push(JSON.parse(line));
54
+ } catch {}
55
+ }
56
+ return out;
57
+ } finally {
58
+ fs.closeSync(fd);
59
+ }
60
+ }
61
+
62
+ /** The engine for one ACP agent. */
63
+ export function acpEngine(name, { command, label, args = [] }) {
64
+ const id = `acp-${name}`;
65
+ const logDir = () => path.join(paths.home, 'acp', name);
66
+ const fileOf = (sid) => path.join(logDir(), `${String(sid).replace(/[^\w.-]/g, '_')}.jsonl`);
67
+ const cmd = [command].flat().map(String);
68
+ return {
69
+ id,
70
+ label: label ?? name,
71
+ acp: true,
72
+ // Bypass answers its permission requests "allow" by itself; other modes ask (the inbox can answer).
73
+ supports: { chooseSessionId: false, cost: false, planHandoff: false, fork: false, oneShot: true, resumePrompt: true },
74
+ permissionModes: ['default', 'bypassPermissions'],
75
+ rulesFiles: ['AGENTS.md'],
76
+ install: `Install ${label ?? name} (\`${cmd[0]}\` on the daemon's PATH) and sign in once in a terminal`,
77
+
78
+ // The command that has to be installed is the agent's; the client is ours.
79
+ bin: () => process.execPath,
80
+ installedBin: () => cmd[0],
81
+ extraArgs: () => [],
82
+
83
+ /** The client in the agent's terminal: the log folder, resume, the first message, then the agent. */
84
+ launchArgs({ permissionMode, writableDir, instructions, sessionId, resume, prompt }) {
85
+ const out = [CLIENT, '--log', logDir()];
86
+ if (permissionMode === 'bypassPermissions') out.push('--yes');
87
+ for (const d of [writableDir ?? []].flat()) out.push('--allow-dir', d);
88
+ if (resume) out.push('--session', sessionId);
89
+ const first = resume ? prompt : [instructions, prompt && (instructions ? `Your task:\n${prompt}` : prompt)].filter(Boolean).join('\n\n---\n\n');
90
+ if (first) out.push('--prompt', first);
91
+ return [...out, '--', ...cmd, ...args];
92
+ },
93
+
94
+ sessionFile: (cwd, sid) => fileOf(sid),
95
+ hasSession: (cwd, sid) => fs.existsSync(fileOf(sid)),
96
+ subagentFiles: () => [],
97
+
98
+ /** Sessions the client started in this folder since a time (from each log's first line). */
99
+ recentSessions(cwd, sinceMs) {
100
+ const want = real(cwd);
101
+ const out = [];
102
+ let names = [];
103
+ try {
104
+ names = fs.readdirSync(logDir()).filter((n) => n.endsWith('.jsonl'));
105
+ } catch {}
106
+ for (const n of names) {
107
+ const file = path.join(logDir(), n);
108
+ const st = fs.statSync(file);
109
+ if (st.mtimeMs < sinceMs) continue;
110
+ let meta = null;
111
+ try {
112
+ meta = JSON.parse(fs.readFileSync(file, 'utf8').split('\n')[0]);
113
+ } catch {}
114
+ if (meta?.type !== 'meta' || real(meta.cwd) !== want) continue;
115
+ out.push({ sessionId: meta.sessionId, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs });
116
+ }
117
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
118
+ },
119
+
120
+ /** Usage (the agent's context use, when it reports it), PR and bug links from messages and tool output. */
121
+ scan(file, cursor, bugPatterns = []) {
122
+ const result = { prs: [], bugs: [], title: null, lastPrompt: null, cost: null, lastActivity: null };
123
+ cursor.usage ??= new Map();
124
+ cursor.offset ??= 0;
125
+ const recs = newRecords(file, cursor);
126
+ result.lastActivity = cursor.lastActivity ?? null;
127
+ if (!recs) return result;
128
+ for (const r of recs) {
129
+ if (r.type === 'user') result.lastPrompt = String(r.text ?? '').slice(0, 500);
130
+ if (r.type === 'usage' && r.used != null) cursor.usage.set('context', { input: r.used, output: 0, cacheCreation: 0, cacheRead: 0 });
131
+ const text = r.type === 'agent' || r.type === 'user' ? r.text : r.type === 'tool' ? r.output : '';
132
+ if (text) {
133
+ const refs = extractRefs(String(text), bugPatterns);
134
+ result.prs.push(...refs.prs);
135
+ result.bugs.push(...refs.bugs);
136
+ }
137
+ }
138
+ return result;
139
+ },
140
+
141
+ /** Commands and edits, for stuck detection. */
142
+ activity(file, state) {
143
+ state.events ??= [];
144
+ state.offset ??= 0;
145
+ state.inputs ??= {};
146
+ const recs = newRecords(file, state);
147
+ if (!recs) return state;
148
+ for (const r of recs) {
149
+ const t = Date.parse(r.t) || Date.now();
150
+ if (r.type === 'tool' && r.input) state.inputs[r.id] = r.input;
151
+ if (r.type === 'tool' && (r.status === 'completed' || r.status === 'failed') && r.kind === 'execute') {
152
+ const input = r.input ?? state.inputs[r.id] ?? {};
153
+ const out = String(r.output ?? '');
154
+ state.events.push({ t, kind: 'cmd', cmd: norm(String(input.command ?? r.title ?? '')).slice(0, 300), failed: Boolean(r.failed), result: tailOf(out), shown: out.replace(/\s+/g, ' ').trim().slice(-160) });
155
+ } else if (r.type === 'edit') {
156
+ state.events.push({ t, kind: 'edit', file: r.path, edits: [[norm(r.oldText ?? ''), norm(r.newText ?? '')]] });
157
+ }
158
+ }
159
+ if (state.events.length > 400) state.events = state.events.slice(-400);
160
+ return state;
161
+ },
162
+
163
+ /** A readable digest of the conversation, for Explain and documents. */
164
+ digest(file, { maxChars = 24_000 } = {}) {
165
+ const events = [];
166
+ const edited = new Set();
167
+ for (const r of newRecords(file, { offset: 0 }) ?? []) {
168
+ if (r.type === 'user') events.push(`[user] ${String(r.text).slice(0, 1200)}`);
169
+ else if (r.type === 'agent') events.push(`[agent] ${String(r.text).slice(0, 1800)}`);
170
+ else if (r.type === 'tool' && (r.status === 'completed' || r.status === 'failed')) events.push(`[action] ${r.title}${r.failed ? ' (failed)' : ''}`);
171
+ else if (r.type === 'edit') edited.add(r.path), events.push(`[action] edited ${r.path}`);
172
+ }
173
+ const head = events.find((e) => e.startsWith('[user]'));
174
+ const tail = [];
175
+ let used = head?.length ?? 0;
176
+ for (let i = events.length - 1; i >= 0 && events[i] !== head; i--) {
177
+ if (used + events[i].length > maxChars) {
178
+ tail.unshift('… (earlier events omitted) …');
179
+ break;
180
+ }
181
+ tail.unshift(events[i]);
182
+ used += events[i].length + 1;
183
+ }
184
+ return { digest: [head, ...tail].filter(Boolean).join('\n'), events: events.length, edited: [...edited] };
185
+ },
186
+
187
+ copySession: () => false,
188
+
189
+ attention: [[/Permissionneeded:/i, 'Permission prompt']],
190
+ // Its menu reads a line: a choice is the number, then Enter.
191
+ promptKeyEnter: true,
192
+
193
+ /** The client's menu: "Permission needed: <title> 1. Allow once 2. Always allow 3. Reject Choose 1-3". */
194
+ promptChoices(screen) {
195
+ const text = String(screen ?? '').replace(ANSI, ' ').replace(/\s+/g, ' ');
196
+ const at = text.lastIndexOf('Permission needed:');
197
+ if (at < 0) return { question: '', choices: [] };
198
+ const tail = text.slice(at);
199
+ const question = (/^Permission needed: (.*?)(?= 1\. )/.exec(tail)?.[1] ?? '').trim().slice(0, 200);
200
+ const choices = [];
201
+ for (const m of tail.matchAll(/\b(\d)\. (.+?)(?= \d\. | Choose |$)/g)) choices.push({ key: m[1], label: m[2].trim().slice(0, 80) });
202
+ return { question: question ? `Allow: ${question}?` : 'Allow this?', choices };
203
+ },
204
+
205
+ trust: () => false,
206
+
207
+ /** One-shot: the client in --once mode, every permission refused (read-only), the prompt on stdin. */
208
+ oneShotArgs: () => [CLIENT, '--once', '--', ...cmd, ...args],
209
+
210
+ stripEnv: [],
211
+ };
212
+ }
213
+
214
+ /** The ACP engines to offer: the presets, with config's own added or changed. */
215
+ export function acpEngines(config = {}) {
216
+ const defs = { ...ACP_PRESETS };
217
+ for (const [name, def] of Object.entries(config.acp ?? {})) {
218
+ if (def === false) delete defs[name];
219
+ else if (def?.command) defs[name] = { ...defs[name], ...def };
220
+ }
221
+ return Object.entries(defs).map(([name, def]) => acpEngine(name, def));
222
+ }
@@ -6,6 +6,7 @@ import antigravity from './antigravity.js';
6
6
  import claude from './claude.js';
7
7
  import codex from './codex.js';
8
8
  import muse from './muse.js';
9
+ import { acpEngines } from './acp.js';
9
10
 
10
11
  export const ENGINES = { claude, codex, antigravity, muse };
11
12
  export const DEFAULT_ENGINE = 'claude';
@@ -17,16 +18,23 @@ export function registerEngine(def) {
17
18
  ENGINES[def.id] = def;
18
19
  }
19
20
 
21
+ /** ACP agents from config.json's "acp" and the presets (acp.js); call again when the config changes. */
22
+ export function registerAcpEngines(config = {}) {
23
+ for (const id of Object.keys(ENGINES)) if (ENGINES[id].acp) delete ENGINES[id];
24
+ for (const e of acpEngines(config)) ENGINES[e.id] = e;
25
+ }
26
+
20
27
  /** Whether an engine's command is on this machine (an absolute path, or found on PATH). */
21
28
  export function installed(engine, config) {
22
- const bin = engine.bin(config);
29
+ // An ACP engine runs our client with Node; what has to be installed is the agent's own command.
30
+ const bin = engine.installedBin?.(config) ?? engine.bin(config);
23
31
  if (path.isAbsolute(bin)) return fs.existsSync(bin);
24
32
  return (process.env.PATH ?? '').split(path.delimiter).some((d) => d && fs.existsSync(path.join(d, bin)));
25
33
  }
26
34
 
27
35
  /** What to tell the person when an engine's command isn't on this machine. */
28
36
  export function notInstalled(engine, config) {
29
- const bin = engine.bin(config);
37
+ const bin = engine.installedBin?.(config) ?? engine.bin(config);
30
38
  const how = engine.install ?? `Install ${engine.label}`;
31
39
  const where = path.isAbsolute(bin) ? `${bin} doesn't exist` : `\`${bin}\` isn't on the daemon's PATH`;
32
40
  return `${engine.label} not found: ${where}. ${how}, then run \`agentmgr restart\`. If it's installed somewhere else, set its path in config.json${engine.id === 'claude' ? ' ("claudeBin") or AGENTMGR_CLAUDE_BIN' : ` ("engines": {"${engine.id}": {"bin": "…"}})`}.`;
package/src/fleet.js CHANGED
@@ -158,19 +158,20 @@ export const isLoopback = (h) => ['127.0.0.1', 'localhost', '[::1]', '::1'].incl
158
158
 
159
159
  /**
160
160
  * Hub URLs a runner accepts: https anywhere; plain http only on this machine or over
161
- * Tailscale, which already encrypts the traffic.
161
+ * Tailscale, which already encrypts the traffic, or on a private network you vouch for
162
+ * (AGENTMGR_HUB_ALLOW_HTTP=1: a hub and runners in one docker-compose or VPC).
162
163
  */
163
- export function checkHubUrl(raw) {
164
+ export function checkHubUrl(raw, env = process.env) {
164
165
  let u;
165
166
  try {
166
167
  u = new URL(String(raw ?? '').trim());
167
168
  } catch {
168
169
  throw Object.assign(new Error('Hub URL must look like https://hub.example.com'), { status: 400 });
169
170
  }
170
- if (u.protocol === 'https:' || (u.protocol === 'http:' && (isLoopback(u.hostname) || isTailscaleHost(u.hostname)))) {
171
+ if (u.protocol === 'https:' || (u.protocol === 'http:' && (isLoopback(u.hostname) || isTailscaleHost(u.hostname) || env.AGENTMGR_HUB_ALLOW_HTTP === '1'))) {
171
172
  return u.origin;
172
173
  }
173
- throw Object.assign(new Error('Use https:// for the hub (plain http only for localhost or a Tailscale address)'), { status: 400 });
174
+ throw Object.assign(new Error('Use https:// for the hub (plain http only for localhost, a Tailscale address, or a private network with AGENTMGR_HUB_ALLOW_HTTP=1)'), { status: 400 });
174
175
  }
175
176
 
176
177
  /** Signed by the runner's key on every connection, so the hub knows which runner it is. */
package/src/hub.js CHANGED
@@ -61,6 +61,12 @@ function newCode() {
61
61
  const chars = [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
62
62
  return chars.match(/.{4}/g).join('-');
63
63
  }
64
+ /** Constant-time comparison of two strings (different lengths are simply unequal). */
65
+ const safeEqual = (a, b) => {
66
+ const x = Buffer.from(String(a));
67
+ const y = Buffer.from(String(b));
68
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
69
+ };
64
70
  const normalizeCode = (c) => String(c ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
65
71
 
66
72
  /** Google sign-in settings from the environment, or null when not configured. */
@@ -459,7 +465,11 @@ export async function startHub({
459
465
  if (!loginAllowed(req.ip)) throw fail(429, 'Too many attempts; try again in 15 minutes');
460
466
  const key = sha256(normalizeCode(req.body?.code));
461
467
  const invite = store.data.invites[key];
462
- if (!invite || Date.parse(invite.expiresAt) < Date.now()) {
468
+ // The runner token (AGENTMGR_HUB_RUNNER_TOKEN): a reusable secret for machines that join by
469
+ // themselves, such as cloud runners; compared in constant time, and failures count like any.
470
+ const token = process.env.AGENTMGR_HUB_RUNNER_TOKEN;
471
+ const viaToken = Boolean(token && token.length >= 24 && safeEqual(String(req.body?.code ?? ''), token));
472
+ if (!viaToken && (!invite || Date.parse(invite.expiresAt) < Date.now())) {
463
473
  loginFailed(req.ip);
464
474
  throw fail(401, 'Invite code is wrong, used or expired; create a new one on the hub');
465
475
  }
@@ -471,7 +481,7 @@ export async function startHub({
471
481
  } catch {
472
482
  throw fail(400, 'publicKey must be an Ed25519 public key (PEM)');
473
483
  }
474
- delete store.data.invites[key]; // single use
484
+ if (!viaToken) delete store.data.invites[key]; // single use
475
485
  const base = String(req.body?.name ?? '').toLowerCase().replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'machine';
476
486
  const taken = new Set(Object.values(store.data.runners).map((r) => r.name));
477
487
  let runnerName = base;
@@ -479,7 +489,7 @@ export async function startHub({
479
489
  const id = crypto.randomBytes(4).toString('hex');
480
490
  store.data.runners[id] = { id, name: runnerName, publicKey, enrolledAt: now(), lastSeen: null, version: String(req.body?.version ?? '').slice(0, 20) || null };
481
491
  store.save();
482
- log(`runner ${runnerName} (${id}) joined from ${req.ip}`);
492
+ log(`runner ${runnerName} (${id}) joined from ${req.ip}${viaToken ? ' with the runner token' : ''}`);
483
493
  changed();
484
494
  return { runnerId: id, name: runnerName, hubName: name };
485
495
  }),
package/src/hublink.js CHANGED
@@ -47,10 +47,12 @@ export function audit(line) {
47
47
  } catch {}
48
48
  }
49
49
 
50
- export function hubPolicy(config) {
50
+ export function hubPolicy(config, env = process.env) {
51
51
  const h = config.hub ?? {};
52
+ // A cloud runner sets it in its environment (AGENTMGR_HUB_ALLOW=view,control,move): no config file to edit.
53
+ const fromEnv = env.AGENTMGR_HUB_ALLOW ? env.AGENTMGR_HUB_ALLOW.split(/[\s,]+/).filter(Boolean) : null;
52
54
  return {
53
- allow: Array.isArray(h.allow) ? h.allow.map(String) : DEFAULT_HUB_POLICY.allow,
55
+ allow: fromEnv ?? (Array.isArray(h.allow) ? h.allow.map(String) : DEFAULT_HUB_POLICY.allow),
54
56
  projects: Array.isArray(h.projects) && h.projects.length ? h.projects.map(String) : DEFAULT_HUB_POLICY.projects,
55
57
  };
56
58
  }
package/src/inbox.js CHANGED
@@ -87,6 +87,20 @@ export function promptChoices(screen) {
87
87
  break;
88
88
  }
89
89
  }
90
+ // Newer Claude Code can draw a menu as one run of text ("Do you want to proceed? ❯ 1. Yes 2. Yes,
91
+ // and switch to auto mode 3. No Esc to cancel"): read the flattened screen after the last question.
92
+ if (!choices.size) {
93
+ const flat = lines.join(' ').replace(/\s+/g, ' ');
94
+ const at = flat.search(/\?\s*(?:[❯>›]\s*)?1[.)]\s*\S[^?]*$/);
95
+ if (at >= 0) {
96
+ const head = flat.slice(0, at + 1);
97
+ // Drop what's left of the box around it (╌ ─ │ and stray letters before them).
98
+ question = (/([^.?!│]*\?)$/.exec(head)?.[1] ?? '').replace(/^.*[╌─│━]+\s*/, '').trim().slice(-200);
99
+ for (const m of flat.slice(at + 1).matchAll(/(?:[❯>›]\s*)?\b(\d)[.)]\s*(.+?)(?=\s+(?:[❯>›]\s*)?\d[.)]\s|\s+Esc to cancel|\s+Enter to|$)/g)) {
100
+ if (!choices.has(m[1])) choices.set(m[1], m[2].replace(/\s*·.*$/, '').trim().slice(0, 80));
101
+ }
102
+ }
103
+ }
90
104
  return {
91
105
  question,
92
106
  choices: [...choices.entries()].sort(([a], [b]) => a - b).map(([key, label]) => ({ key, label })),