ai-agentmanager 1.10.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 +7 -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 +32 -2
- package/public/index.html +14 -1
- package/public/menu.js +39 -0
- package/public/style.css +6 -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
package/public/index.html
CHANGED
|
@@ -245,6 +245,10 @@
|
|
|
245
245
|
<input type="checkbox" name="worktree" checked />
|
|
246
246
|
Give each agent its own git worktree and branch <span class="hint">(for a git folder; always on when cloning a repo URL)</span>
|
|
247
247
|
</label>
|
|
248
|
+
<label class="check" id="container-field" hidden title="The agent sees only its own folder, its git data, brief and uploads, not the rest of this computer. Claude Code and Codex">
|
|
249
|
+
<input type="checkbox" name="container" />
|
|
250
|
+
Run each agent in its own container <span class="hint" id="container-hint">(Docker: it sees only its own folder)</span>
|
|
251
|
+
</label>
|
|
248
252
|
<label class="check">
|
|
249
253
|
<input type="checkbox" name="plan" checked />
|
|
250
254
|
Plan with Claude while the repo clones <span class="hint">(repo URLs only; the session moves into the repo when it's ready)</span>
|
|
@@ -722,6 +726,15 @@
|
|
|
722
726
|
<label class="check"><input type="checkbox" name="startAtLogin" /> Start AI Agent Manager when I log in</label>
|
|
723
727
|
<p class="hint">After a restart or a crash, the daemon comes back by itself and running agents resume; without it, it starts only when you run <code>agentmgr</code>. <span id="settings-service-state"></span></p>
|
|
724
728
|
</fieldset>
|
|
729
|
+
<fieldset class="local-only" id="settings-containers">
|
|
730
|
+
<legend>Containers</legend>
|
|
731
|
+
<p class="hint">Agents in a container (Docker) see only their own folder, its git data, brief and uploads: not the rest of this computer. <span id="settings-containers-state"></span></p>
|
|
732
|
+
<div class="row">
|
|
733
|
+
<label>Claude token for containers <span class="hint">run <code>claude setup-token</code> in a terminal and paste it</span><input name="claudeToken" type="password" autocomplete="new-password" /></label>
|
|
734
|
+
<div class="containers-actions"><button type="button" class="small" id="containers-build">Build image</button></div>
|
|
735
|
+
</div>
|
|
736
|
+
<label class="check"><input type="checkbox" name="containerDefault" /> Run new agents in containers by default</label>
|
|
737
|
+
</fieldset>
|
|
725
738
|
<fieldset class="local-only">
|
|
726
739
|
<legend>Jira</legend>
|
|
727
740
|
<p class="hint">For an agent created from a Jira link, its description shows the issue's summary. Public issues on Atlassian Cloud need nothing here; for your company's site, add it with your email and an <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener">API token</a>. The token is kept on this computer and sent only to that site.</p>
|
|
@@ -738,7 +751,7 @@
|
|
|
738
751
|
</fieldset>
|
|
739
752
|
<fieldset>
|
|
740
753
|
<legend>More</legend>
|
|
741
|
-
<p class="hint settings-links"><button type="button" class="link" data-open="cleanup-btn">Clean up old agents</button
|
|
754
|
+
<p class="hint settings-links"><button type="button" class="link" data-open="cleanup-btn">Clean up old agents</button><button type="button" class="link" data-open="spend-btn">Spend and budgets</button><button type="button" class="link local-only" data-open="alerts-btn">Slack alerts</button><button type="button" class="link local-only" data-open="remote-btn">Remote access</button></p>
|
|
742
755
|
</fieldset>
|
|
743
756
|
<p id="settings-error" class="error" hidden></p>
|
|
744
757
|
<div class="dialog-actions">
|
package/public/menu.js
CHANGED
|
@@ -55,6 +55,36 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
55
55
|
const form = $('#settings-form');
|
|
56
56
|
let service = null; // the login service as the dialog opened
|
|
57
57
|
let jira = null; // the Jira settings as the dialog opened
|
|
58
|
+
let containers = null; // Docker and the agent image as the dialog opened
|
|
59
|
+
function showContainers() {
|
|
60
|
+
const c = containers;
|
|
61
|
+
$('#settings-containers').hidden = !c;
|
|
62
|
+
if (!c) return;
|
|
63
|
+
$('#settings-containers-state').textContent = !c.available
|
|
64
|
+
? `Docker isn't running here${c.error ? ` (${c.error})` : ''}.`
|
|
65
|
+
: `Docker ${c.version} · image ${c.image}: ${c.building ? 'building…' : c.imageBuilt ? 'built' : 'not built yet'}${c.buildError ? ` (last build failed: ${c.buildError})` : ''} · Claude token ${c.hasClaudeToken ? 'set' : 'not set'}.`;
|
|
66
|
+
$('#containers-build').disabled = !c.available || c.building;
|
|
67
|
+
$('#containers-build').textContent = c.building ? 'Building…' : c.imageBuilt ? 'Rebuild image' : 'Build image';
|
|
68
|
+
form.containerDefault.disabled = !c.available;
|
|
69
|
+
}
|
|
70
|
+
$('#containers-build').onclick = async () => {
|
|
71
|
+
try {
|
|
72
|
+
containers = await api('POST', '/isolation/build');
|
|
73
|
+
showContainers();
|
|
74
|
+
toast('Building the agent image; it takes a few minutes the first time', 6000);
|
|
75
|
+
// Watch it finish.
|
|
76
|
+
const t = setInterval(async () => {
|
|
77
|
+
containers = await api('GET', '/isolation').catch(() => containers);
|
|
78
|
+
if (dialog.open) showContainers();
|
|
79
|
+
if (!containers?.building) {
|
|
80
|
+
clearInterval(t);
|
|
81
|
+
toast(containers?.imageBuilt && !containers.buildError ? 'Agent image built: agents can run in containers' : `Image build failed: ${containers?.buildError ?? 'see the daemon log'}`, 8000);
|
|
82
|
+
}
|
|
83
|
+
}, 5000);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
toast(`Error: ${err.message}`, 6000);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
58
88
|
$('#settings-btn').onclick = async () => {
|
|
59
89
|
$('#settings-error').hidden = true;
|
|
60
90
|
try {
|
|
@@ -70,6 +100,11 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
70
100
|
form.jiraEmail.value = jira?.email ?? '';
|
|
71
101
|
form.jiraToken.value = '';
|
|
72
102
|
form.jiraToken.placeholder = jira?.hasToken ? 'saved (type to replace)' : '';
|
|
103
|
+
containers = await api('GET', '/isolation').catch(() => null);
|
|
104
|
+
showContainers();
|
|
105
|
+
form.claudeToken.value = '';
|
|
106
|
+
form.claudeToken.placeholder = containers?.hasClaudeToken ? 'saved (type to replace)' : 'from claude setup-token';
|
|
107
|
+
form.containerDefault.checked = containers?.default === 'container';
|
|
73
108
|
service = await api('GET', '/service').catch(() => null);
|
|
74
109
|
$('#settings-service').hidden = !service?.supported;
|
|
75
110
|
form.startAtLogin.checked = Boolean(service?.installed);
|
|
@@ -96,6 +131,10 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
96
131
|
await api('PUT', '/jira', { site: form.jiraSite.value.trim(), email: form.jiraEmail.value.trim(), token: form.jiraToken.value.trim() || undefined });
|
|
97
132
|
toast('Jira saved; summaries for agents from Jira links are being fetched', 4000);
|
|
98
133
|
}
|
|
134
|
+
if (!remote() && containers?.available) {
|
|
135
|
+
if (form.claudeToken.value.trim()) await api('PUT', '/isolation/token', { token: form.claudeToken.value.trim() });
|
|
136
|
+
if (form.containerDefault.checked !== (containers.default === 'container')) await api('PUT', '/isolation/default', { container: form.containerDefault.checked });
|
|
137
|
+
}
|
|
99
138
|
// Only when it changed: installing touches launchd / systemd.
|
|
100
139
|
if (!remote() && service?.supported && form.startAtLogin.checked !== Boolean(service.installed)) {
|
|
101
140
|
const s = await api('PUT', '/service', { enabled: form.startAtLogin.checked });
|
package/public/style.css
CHANGED
|
@@ -934,3 +934,9 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
|
|
|
934
934
|
.rv-hunk.flash td { animation: rvflash 1.6s; }
|
|
935
935
|
@keyframes rvflash { 0%, 60% { background: var(--accent); color: var(--accent-text); } }
|
|
936
936
|
@media (max-width: 640px) { .sc-row { grid-template-columns: 56px 1fr 28px; } .sc-why { grid-column: 1 / -1; } }
|
|
937
|
+
|
|
938
|
+
.containers-actions { display: flex; align-items: flex-end; }
|
|
939
|
+
|
|
940
|
+
/* Settings → More: separators belong to the links, so hidden (local-only) ones leave none behind. */
|
|
941
|
+
.settings-links button + button::before { content: '·'; display: inline-block; margin: 0 8px; color: var(--muted); }
|
|
942
|
+
body.remote .settings-links .local-only { display: none; }
|
package/src/backlog.js
CHANGED
|
@@ -89,7 +89,7 @@ export class Backlog extends EventEmitter {
|
|
|
89
89
|
// ------------------------------------------------------------ editing
|
|
90
90
|
|
|
91
91
|
/** Queue one item per title. Options are what an agent would take (repo, dir, base, project, ...). */
|
|
92
|
-
add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, attachments, priority = 2 } = {}) {
|
|
92
|
+
add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, attachments, isolation, priority = 2 } = {}) {
|
|
93
93
|
const list = titles.map((t) => String(t ?? '').trim()).filter(Boolean);
|
|
94
94
|
if (!list.length) throw err(400, 'At least one item is required');
|
|
95
95
|
const pr = parsePriority(priority);
|
|
@@ -122,6 +122,8 @@ export class Backlog extends EventEmitter {
|
|
|
122
122
|
engine: engine || null,
|
|
123
123
|
// Staged uploads; each item's agent gets its own copies when it starts.
|
|
124
124
|
attachments: Array.isArray(attachments) && attachments.length ? attachments.slice(0, 20).map(String) : undefined,
|
|
125
|
+
// "container", "" (on this computer), or undefined (the default when it starts).
|
|
126
|
+
isolation: isolation === undefined ? undefined : isolation || '',
|
|
125
127
|
plan: Boolean(plan),
|
|
126
128
|
planReview: planReview ?? null,
|
|
127
129
|
priority: pr,
|
|
@@ -222,6 +224,7 @@ export class Backlog extends EventEmitter {
|
|
|
222
224
|
model: it.model,
|
|
223
225
|
engine: it.engine ?? undefined,
|
|
224
226
|
attachments: it.attachments,
|
|
227
|
+
isolation: it.isolation,
|
|
225
228
|
plan: it.plan,
|
|
226
229
|
planReview: it.planReview ?? undefined,
|
|
227
230
|
priority: it.priority,
|
package/src/cli.js
CHANGED
|
@@ -343,6 +343,8 @@ export async function main(argv) {
|
|
|
343
343
|
.option('-m, --model <model>', 'model (for the chosen engine)')
|
|
344
344
|
.option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
|
|
345
345
|
.option('--file-attach <path>', 'attach a file to the request (a screenshot, a log); repeat for more', collect)
|
|
346
|
+
.option('--container', 'run each agent in its own Docker container (see agentmgr sandbox)')
|
|
347
|
+
.option('--no-container', 'run on this computer even if containers are the default')
|
|
346
348
|
.option('--plan', 'while a repo URL clones, plan the task with Claude (moves into the repo when ready)')
|
|
347
349
|
.option('--no-start', 'create without launching')
|
|
348
350
|
.option('-a, --attach', 'attach to the agent after launch (single intent only)')
|
|
@@ -357,6 +359,7 @@ export async function main(argv) {
|
|
|
357
359
|
const created = await api('POST', '/agents', {
|
|
358
360
|
intents,
|
|
359
361
|
attachments,
|
|
362
|
+
isolation: cmd.getOptionValueSource('container') === 'cli' ? (opts.container ? 'container' : '') : undefined,
|
|
360
363
|
repo: opts.repo,
|
|
361
364
|
dir: opts.dir,
|
|
362
365
|
name: opts.name,
|
|
@@ -383,6 +386,39 @@ export async function main(argv) {
|
|
|
383
386
|
}
|
|
384
387
|
});
|
|
385
388
|
|
|
389
|
+
const sb = program.command('sandbox').description('Agents in Docker containers: status, building the agent image, the Claude token');
|
|
390
|
+
sb.command('status')
|
|
391
|
+
.description('Whether Docker runs, the agent image is built, and containers can sign in to Claude')
|
|
392
|
+
.action(async () => {
|
|
393
|
+
await daemon();
|
|
394
|
+
const s = await api('GET', '/isolation');
|
|
395
|
+
console.log(`Docker: ${s.available ? green(`running (${s.version})`) : yellow(`not available${s.error ? `: ${s.error}` : ''}`)}`);
|
|
396
|
+
console.log(`Agent image: ${s.image} ${s.imageBuilt ? green('built') : s.building ? yellow('building…') : yellow('not built (agentmgr sandbox build)')}`);
|
|
397
|
+
console.log(`Claude token: ${s.hasClaudeToken ? green('set') : yellow('not set (run `claude setup-token`, then agentmgr sandbox token <token>)')}`);
|
|
398
|
+
console.log(`Default: ${s.default === 'container' ? 'new agents run in containers' : 'new agents run on this computer'}`);
|
|
399
|
+
if (s.buildError) console.log(yellow(`Last build failed: ${s.buildError}`));
|
|
400
|
+
});
|
|
401
|
+
sb.command('build')
|
|
402
|
+
.description('Build the image agents run in (Node, git, Claude Code, Codex); a few minutes the first time')
|
|
403
|
+
.action(async () => {
|
|
404
|
+
const { buildImage, isolationSettings } = await import('./container.js');
|
|
405
|
+
const { loadConfig } = await import('./config.js');
|
|
406
|
+
const cfg = loadConfig();
|
|
407
|
+
console.log(dim(`building ${isolationSettings(cfg).image}…`));
|
|
408
|
+
await buildImage(cfg, { log: (l) => process.stderr.write(dim(l)) });
|
|
409
|
+
console.log(`${green('✓')} built ${isolationSettings(cfg).image}`);
|
|
410
|
+
});
|
|
411
|
+
sb.command('token')
|
|
412
|
+
.description('Save the Claude Code token containers sign in with (from `claude setup-token`); --clear removes it')
|
|
413
|
+
.argument('[token]')
|
|
414
|
+
.option('--clear', 'remove the saved token')
|
|
415
|
+
.action(async (token, opts) => {
|
|
416
|
+
const { saveSecrets } = await import('./container.js');
|
|
417
|
+
if (!opts.clear && !token) throw new Error('Give the token from `claude setup-token`, or --clear');
|
|
418
|
+
saveSecrets({ claudeOauthToken: opts.clear ? null : token.trim() });
|
|
419
|
+
console.log(opts.clear ? 'Token removed' : `${green('✓')} token saved (in ~/.agentmanager/secrets.json, readable only by you)`);
|
|
420
|
+
});
|
|
421
|
+
|
|
386
422
|
program
|
|
387
423
|
.command('ask')
|
|
388
424
|
.description('Open a general session (no repo or folder) on any engine and attach to it')
|
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": "…"}})`}.`;
|