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.
- package/CHANGELOG.md +11 -0
- package/README.md +27 -3
- package/bin/acp-client.js +332 -0
- package/docker/agent.Dockerfile +15 -0
- package/docker/compose.runner.yml +22 -0
- package/docker/runner-entrypoint.sh +12 -0
- package/docker/runner.Dockerfile +31 -0
- package/docs/cloud-runner.md +62 -0
- package/package.json +2 -1
- package/public/app.js +40 -8
- package/public/index.html +14 -1
- package/public/land.js +2 -2
- package/public/menu.js +39 -0
- package/public/review.js +54 -2
- package/public/scorecard.js +68 -0
- package/public/style.css +34 -0
- package/src/backlog.js +4 -1
- package/src/cli.js +38 -2
- package/src/container.js +189 -0
- package/src/engines/acp.js +222 -0
- package/src/engines/index.js +10 -2
- package/src/fleet.js +5 -4
- package/src/hub.js +13 -3
- package/src/hublink.js +4 -2
- package/src/inbox.js +14 -0
- package/src/manager.js +90 -8
- package/src/review.js +34 -2
- package/src/scorecard.js +189 -0
- package/src/server.js +36 -2
- package/src/trust.js +1 -2
package/src/container.js
ADDED
|
@@ -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
|
+
}
|
package/src/engines/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 })),
|