ai-agentmanager 1.10.0 → 1.11.1
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 +26 -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 +37 -2
- package/public/index.html +18 -1
- package/public/machine.js +4 -0
- package/public/menu.js +39 -0
- package/public/style.css +15 -0
- package/public/termclip.js +92 -0
- package/src/backlog.js +4 -1
- package/src/cli.js +36 -0
- 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 +50 -8
- package/src/server.js +35 -2
- package/src/trust.js +1 -2
|
@@ -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 })),
|
package/src/manager.js
CHANGED
|
@@ -102,6 +102,7 @@ import * as git from './git.js';
|
|
|
102
102
|
import { parseDiff } from '../public/review.js';
|
|
103
103
|
import { issueSummary, leadingIssue, wordsAfterLink } from './jira.js';
|
|
104
104
|
import { combine, facts as scoreFacts, scorecardMarkdown, scorecardPrompt } from './scorecard.js';
|
|
105
|
+
import { CONTAINER_ENGINES, claudeToken, containerName, containerRunning, engineMounts, gitCommonDir, removeContainer, runArgs, stopContainer } from './container.js';
|
|
105
106
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
106
107
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
107
108
|
import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
|
|
@@ -594,6 +595,7 @@ export class AgentManager extends EventEmitter {
|
|
|
594
595
|
startedBy,
|
|
595
596
|
trigger,
|
|
596
597
|
attachments,
|
|
598
|
+
isolation,
|
|
597
599
|
}) {
|
|
598
600
|
intent = (intent ?? '').trim();
|
|
599
601
|
const staged = stagedFiles(attachments);
|
|
@@ -607,6 +609,12 @@ export class AgentManager extends EventEmitter {
|
|
|
607
609
|
}
|
|
608
610
|
// Planning while cloning moves the session between folders, which needs the engine's support.
|
|
609
611
|
if (!engineDef.supports.planHandoff) plan = false;
|
|
612
|
+
// Isolation: "container" runs it in Docker (config "isolation": { "default": "container" } for all).
|
|
613
|
+
isolation = isolation === undefined ? this.config.isolation?.default ?? null : isolation || null;
|
|
614
|
+
if (isolation && isolation !== 'container') throw Object.assign(new Error('isolation is "container" or nothing'), { status: 400 });
|
|
615
|
+
if (isolation && !CONTAINER_ENGINES.has(engineId)) throw Object.assign(new Error(`${engineDef.label} can't run in a container yet (Claude Code and Codex can)`), { status: 400 });
|
|
616
|
+
// The session moves between folders mid-plan; a container is bound to one folder.
|
|
617
|
+
if (isolation) plan = false;
|
|
610
618
|
const general = NO_REPO.has(kind);
|
|
611
619
|
// A project shortcut (or name) fills in the repo / folder / base branch.
|
|
612
620
|
const entry = !general && project ? this.resolveProject(project) : null;
|
|
@@ -639,6 +647,7 @@ export class AgentManager extends EventEmitter {
|
|
|
639
647
|
priority: parsePriority(priority),
|
|
640
648
|
...(startedBy ? { startedBy } : {}),
|
|
641
649
|
...(trigger ? { trigger } : {}),
|
|
650
|
+
...(isolation ? { isolation } : {}),
|
|
642
651
|
name: name?.trim() || jiraKey || slug,
|
|
643
652
|
slug,
|
|
644
653
|
intent,
|
|
@@ -995,6 +1004,26 @@ export class AgentManager extends EventEmitter {
|
|
|
995
1004
|
return this.view(this.store.get(agent.id));
|
|
996
1005
|
}
|
|
997
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* `docker run` for an agent in a container (see container.js): its folder, its repository's git
|
|
1009
|
+
* data, its brief and uploads, the engine's homes and sign-in; nothing else of this computer.
|
|
1010
|
+
*/
|
|
1011
|
+
containerSpec(agent, engine, cwd, args) {
|
|
1012
|
+
const name = containerName(agent.id);
|
|
1013
|
+
removeContainer(name); // a leftover from a crash or a restart would block the name
|
|
1014
|
+
const em = engineMounts(engine.id, cwd);
|
|
1015
|
+
const real = (p) => (fs.existsSync(p) ? fs.realpathSync(p) : null);
|
|
1016
|
+
const git = gitCommonDir(cwd);
|
|
1017
|
+
// Plus any folders config.json adds ("isolation": { "mounts": [{ "host": "~/.npm", "readOnly": true }] }).
|
|
1018
|
+
const extra = (this.config.isolation?.mounts ?? []).map((m) => ({ ...m, host: real(expandHome(m.host)) }));
|
|
1019
|
+
const mounts = [...em.mounts, ...(git ? [{ host: git }] : []), { host: real(briefDir(agent.id)) }, { host: real(path.join(paths.uploads, agent.id)), readOnly: true }, ...extra].filter((m) => m.host);
|
|
1020
|
+
const bin = this.config.isolation?.bin?.[engine.id] ?? em.bin;
|
|
1021
|
+
const env = { ...this.childEnv(agent), ...(claudeToken() ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken() } : {}) };
|
|
1022
|
+
// Variables passed in by name (values stay out of the command line): the engine's, plus config's "isolation.env".
|
|
1023
|
+
const inner = ['TERM=xterm-256color', 'COLORTERM=truecolor', `AGENTMGR_AGENT_ID=${agent.id}`, 'AGENTMGR_TERMINAL=1', ...em.env, ...(this.config.isolation?.env ?? [])];
|
|
1024
|
+
return { name, cmd: 'docker', env, args: runArgs({ agentId: agent.id, engineId: engine.id, cwd: fs.realpathSync(cwd), bin, args, config: this.config, mounts, env: inner }) };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
998
1027
|
/** What every launch of this agent passes its engine: options, its brief folder, instructions. */
|
|
999
1028
|
launchOptions(agent, { planning = false } = {}) {
|
|
1000
1029
|
const engine = engineOf(agent);
|
|
@@ -1098,19 +1127,23 @@ export class AgentManager extends EventEmitter {
|
|
|
1098
1127
|
}
|
|
1099
1128
|
}
|
|
1100
1129
|
|
|
1101
|
-
|
|
1130
|
+
// In a container the engine is inside the image, not on this computer.
|
|
1131
|
+
const container = agent.isolation === 'container' && !NO_REPO.has(agent.kind);
|
|
1132
|
+
if (!container && !installed(engine, this.config)) {
|
|
1102
1133
|
const error = notInstalled(engine, this.config);
|
|
1103
1134
|
this.patch(agent.id, { status: 'error', error });
|
|
1104
1135
|
throw Object.assign(new Error(error), { status: 400 });
|
|
1105
1136
|
}
|
|
1106
1137
|
let proc;
|
|
1107
1138
|
try {
|
|
1108
|
-
|
|
1139
|
+
const spec = container ? this.containerSpec(agent, engine, cwd, args) : { cmd: engine.bin(this.config), args, env: this.childEnv(agent) };
|
|
1140
|
+
rt.container = spec.name ?? null;
|
|
1141
|
+
proc = pty.spawn(spec.cmd, spec.args, {
|
|
1109
1142
|
name: 'xterm-256color',
|
|
1110
1143
|
cols: rt.cols,
|
|
1111
1144
|
rows: rt.rows,
|
|
1112
1145
|
cwd,
|
|
1113
|
-
env:
|
|
1146
|
+
env: spec.env,
|
|
1114
1147
|
});
|
|
1115
1148
|
} catch (err) {
|
|
1116
1149
|
this.patch(agent.id, { status: 'error', error: `Failed to launch ${engine.bin(this.config)}: ${err.message}` });
|
|
@@ -1233,9 +1266,14 @@ export class AgentManager extends EventEmitter {
|
|
|
1233
1266
|
done.dispose();
|
|
1234
1267
|
resolve(this.view(this.store.get(agent.id) ?? agent));
|
|
1235
1268
|
});
|
|
1236
|
-
// A
|
|
1237
|
-
|
|
1238
|
-
rt.
|
|
1269
|
+
// A container: docker stop (the engine gets SIGTERM inside; the docker command then ends).
|
|
1270
|
+
// Otherwise a hangup, as when a terminal closes, unless the engine ends cleanly on another signal.
|
|
1271
|
+
if (rt.container) stopContainer(rt.container, 10);
|
|
1272
|
+
else rt.pty.kill(engineOf(agent).stopSignal ?? 'SIGHUP');
|
|
1273
|
+
rt.killTimer = setTimeout(() => {
|
|
1274
|
+
if (rt.container) removeContainer(rt.container);
|
|
1275
|
+
rt.pty?.kill('SIGKILL');
|
|
1276
|
+
}, timeoutMs);
|
|
1239
1277
|
});
|
|
1240
1278
|
}
|
|
1241
1279
|
|
|
@@ -1509,7 +1547,10 @@ export class AgentManager extends EventEmitter {
|
|
|
1509
1547
|
rows: prev?.rows ?? 34,
|
|
1510
1548
|
};
|
|
1511
1549
|
if (rt.size) this.appendOutput(rt, '\r\n\x1b[2m── new shell ──\x1b[0m\r\n');
|
|
1512
|
-
|
|
1550
|
+
// An agent in a running container: the shell opens inside it, with what the agent sees.
|
|
1551
|
+
const inside = agent.isolation === 'container' && containerRunning(containerName(agent.id));
|
|
1552
|
+
const [cmd, cmdArgs] = inside ? ['docker', ['exec', '-it', '-w', fs.realpathSync(agent.workdir), containerName(agent.id), 'bash', '-l']] : [this.config.shell, ['-l']];
|
|
1553
|
+
const proc = pty.spawn(cmd, cmdArgs, {
|
|
1513
1554
|
name: 'xterm-256color',
|
|
1514
1555
|
cols: rt.cols,
|
|
1515
1556
|
rows: rt.rows,
|
|
@@ -2812,7 +2853,8 @@ export class AgentManager extends EventEmitter {
|
|
|
2812
2853
|
const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
|
|
2813
2854
|
const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
|
|
2814
2855
|
if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
|
|
2815
|
-
|
|
2856
|
+
// Menus that read a line (the ACP client's) take Enter after the choice.
|
|
2857
|
+
this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
|
|
2816
2858
|
return { ok: true };
|
|
2817
2859
|
}
|
|
2818
2860
|
|
package/src/server.js
CHANGED
|
@@ -25,7 +25,7 @@ import { Studio } from './studio.js';
|
|
|
25
25
|
import { Memory } from './memory.js';
|
|
26
26
|
import { doctor } from './doctor.js';
|
|
27
27
|
import { Triggers, tokenMatches } from './triggers.js';
|
|
28
|
-
import { listEngines, registerEngine } from './engines/index.js';
|
|
28
|
+
import { listEngines, registerAcpEngines, registerEngine } from './engines/index.js';
|
|
29
29
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
30
30
|
import { HubLink, linkUrls } from './hublink.js';
|
|
31
31
|
import { Notifier, isWorkflowUrl, loadNotify, publicNotify, saveNotify, sendSlack } from './notify.js';
|
|
@@ -34,6 +34,7 @@ import { LiveBrowser } from './livebrowser.js';
|
|
|
34
34
|
import * as remote from './remote.js';
|
|
35
35
|
import * as service from './service.js';
|
|
36
36
|
import { jiraView, saveJira } from './jira.js';
|
|
37
|
+
import { CONTAINER_ENGINES, buildImage, claudeToken, dockerStatus, isBuilding, saveSecrets } from './container.js';
|
|
37
38
|
import { Store } from './store.js';
|
|
38
39
|
import { about, latestRelease } from './about.js';
|
|
39
40
|
|
|
@@ -63,7 +64,7 @@ function isAllowedOrigin(origin, port) {
|
|
|
63
64
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
64
65
|
// Updates are not here: a signed-in device (a phone) can install the newest release too. It only
|
|
65
66
|
// ever installs an official release from where this copy came from (git tag or npm) and restarts.
|
|
66
|
-
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
67
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|isolation\/|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
67
68
|
const HOOK_PATH = '/api/hooks/agents';
|
|
68
69
|
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
69
70
|
|
|
@@ -73,6 +74,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
73
74
|
const store = new Store(paths.state);
|
|
74
75
|
const plugins = await new Plugins({ log: (m) => console.log(`[agentmgr] ${m}`) }).load(pluginSpecs(config), { config, base: paths.home });
|
|
75
76
|
for (const { def } of plugins.engines) registerEngine(def);
|
|
77
|
+
// ACP agents (Gemini CLI, OpenCode, Goose, … and config's "acp") become engines.
|
|
78
|
+
registerAcpEngines(config);
|
|
76
79
|
const manager = new AgentManager({ store, config, plugins });
|
|
77
80
|
const backlog = new Backlog({ manager, store, config });
|
|
78
81
|
const races = new Races({ manager, store });
|
|
@@ -513,6 +516,28 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
513
516
|
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
514
517
|
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
515
518
|
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
519
|
+
// Isolation: Docker, the agent image, the Claude token for containers, and the default for new agents.
|
|
520
|
+
let buildError = null;
|
|
521
|
+
const isolationView = async () => ({ ...(await dockerStatus(config)), building: isBuilding(), buildError, hasClaudeToken: Boolean(claudeToken()), engines: [...CONTAINER_ENGINES], default: config.isolation?.default ?? null });
|
|
522
|
+
api.get('/isolation', wrap(isolationView));
|
|
523
|
+
api.post(
|
|
524
|
+
'/isolation/build',
|
|
525
|
+
wrap(async () => {
|
|
526
|
+
buildError = null;
|
|
527
|
+
buildImage(config).catch((err) => (buildError = err.message));
|
|
528
|
+
return isolationView();
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
api.put('/isolation/token', wrap(async (req) => (saveSecrets({ claudeOauthToken: String(req.body?.token ?? '').trim() || null }), isolationView())));
|
|
532
|
+
api.put(
|
|
533
|
+
'/isolation/default',
|
|
534
|
+
wrap(async (req) => {
|
|
535
|
+
const value = req.body?.container ? 'container' : null;
|
|
536
|
+
config.isolation = { ...config.isolation, default: value };
|
|
537
|
+
saveConfig({ isolation: config.isolation });
|
|
538
|
+
return isolationView();
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
516
541
|
// Jira: the site and API token used to show an issue's summary for agents created from its link.
|
|
517
542
|
api.get('/jira', wrap(() => jiraView()));
|
|
518
543
|
api.put(
|
|
@@ -971,6 +996,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
971
996
|
}
|
|
972
997
|
manager.refreshAll().catch(() => {});
|
|
973
998
|
hubLink.start();
|
|
999
|
+
// A cloud runner joins its hub by itself on first start (AGENTMGR_HUB_URL and AGENTMGR_HUB_CODE: an
|
|
1000
|
+
// invite, or the hub's runner token), and stays joined across restarts (the link is kept on disk).
|
|
1001
|
+
if (process.env.AGENTMGR_HUB_URL && process.env.AGENTMGR_HUB_CODE && !hubLink.status().joined) {
|
|
1002
|
+
hubLink
|
|
1003
|
+
.join(process.env.AGENTMGR_HUB_URL, process.env.AGENTMGR_HUB_CODE, process.env.AGENTMGR_RUNNER_NAME || undefined)
|
|
1004
|
+
.then((st) => console.log(`[agentmgr] joined the hub ${st.url} as ${st.name}`))
|
|
1005
|
+
.catch((err) => console.error(`[agentmgr] could not join the hub ${process.env.AGENTMGR_HUB_URL}: ${err.message}`));
|
|
1006
|
+
}
|
|
974
1007
|
return {
|
|
975
1008
|
hubLink,
|
|
976
1009
|
server,
|
package/src/trust.js
CHANGED
|
@@ -16,9 +16,8 @@ export function claudeConfigFile() {
|
|
|
16
16
|
* before writing, only written when something changes, and replaced atomically.
|
|
17
17
|
* Returns true if it changed anything.
|
|
18
18
|
*/
|
|
19
|
-
export function trustFolder(dir) {
|
|
19
|
+
export function trustFolder(dir, file = claudeConfigFile()) {
|
|
20
20
|
const real = fs.realpathSync(dir);
|
|
21
|
-
const file = claudeConfigFile();
|
|
22
21
|
let raw = '{}';
|
|
23
22
|
try {
|
|
24
23
|
raw = fs.readFileSync(file, 'utf8');
|