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
package/public/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import { touchScroll } from '/touchscroll.js';
|
|
|
6
6
|
import { openStream, post, REMOTE } from '/transport.js';
|
|
7
7
|
import { attachBox } from '/attach.js';
|
|
8
8
|
import { initSide } from '/side.js';
|
|
9
|
+
import { openCopy, pasteInto } from '/termclip.js';
|
|
9
10
|
import { addTools, download, htmlFile, initExport } from '/export.js';
|
|
10
11
|
import { badgeHtml as scoreBadge, scorecardHtml, scorecardSpeech } from '/scorecard.js';
|
|
11
12
|
import { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
|
|
@@ -709,7 +710,7 @@ function agentItem(a) {
|
|
|
709
710
|
const idle = !isActive(a);
|
|
710
711
|
return `<li data-id="${a.id}" ${a.kind === 'general' || a.kind === 'assistant' ? '' : 'draggable="true" '}class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
|
|
711
712
|
<div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">⛔</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">✅</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
|
|
712
|
-
<div class="intent" title="${esc(describeTitle(a) ?? '')}">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚡ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">▶ ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(describe(a) || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
|
|
713
|
+
<div class="intent" title="${esc(describeTitle(a) ?? '')}">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚡ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">▶ ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${a.isolation === 'container' ? '<span class="tag engine" title="Runs in its own Docker container: it sees only its own folder">📦 container</span> ' : ''}${esc(describe(a) || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
|
|
713
714
|
${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
|
|
714
715
|
<div class="meta">
|
|
715
716
|
<span class="mono">${a.id}</span>
|
|
@@ -3271,6 +3272,7 @@ function openNewDialog(mode = 'now') {
|
|
|
3271
3272
|
$('#new-error').hidden = true;
|
|
3272
3273
|
dialog.querySelector(`input[name="when"][value="${mode}"]`).checked = true;
|
|
3273
3274
|
restoreIntentSplit();
|
|
3275
|
+
loadIsolation();
|
|
3274
3276
|
syncWhen();
|
|
3275
3277
|
dialog.showModal();
|
|
3276
3278
|
dialog.querySelector('textarea').focus();
|
|
@@ -3433,6 +3435,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3433
3435
|
model: f.get('model') || undefined,
|
|
3434
3436
|
engine: f.get('engine') || undefined,
|
|
3435
3437
|
worktree: f.get('worktree') ? true : undefined,
|
|
3438
|
+
...containerChoice(e.target),
|
|
3436
3439
|
plan: Boolean(f.get('plan')),
|
|
3437
3440
|
planReview: Boolean(f.get('planReview')),
|
|
3438
3441
|
priority: Number(f.get('priority')),
|
|
@@ -3464,6 +3467,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3464
3467
|
plan: Boolean(f.get('plan')),
|
|
3465
3468
|
planReview: Boolean(f.get('planReview')),
|
|
3466
3469
|
start: Boolean(f.get('start')),
|
|
3470
|
+
...containerChoice(e.target),
|
|
3467
3471
|
});
|
|
3468
3472
|
created.forEach((a) => agents.set(a.id, a));
|
|
3469
3473
|
dialog.close();
|
|
@@ -3657,6 +3661,10 @@ const KEYS = {
|
|
|
3657
3661
|
enter: '\r', ctrlc: '\x03', 1: '1', 2: '2', 3: '3', 4: '4',
|
|
3658
3662
|
};
|
|
3659
3663
|
$('#keybar').addEventListener('click', (e) => {
|
|
3664
|
+
// Copy / Paste: a phone can't select xterm's text or paste into it directly (termclip.js).
|
|
3665
|
+
const clip = e.target.closest('[data-clip-act]')?.dataset.clipAct;
|
|
3666
|
+
if (clip === 'copy') return openCopy(term.xterm, { esc, toast });
|
|
3667
|
+
if (clip === 'paste') return pasteInto(term.xterm, { toast });
|
|
3660
3668
|
const key = e.target.closest('[data-key]')?.dataset.key;
|
|
3661
3669
|
if (key) term.send({ t: 'i', d: KEYS[key] });
|
|
3662
3670
|
});
|
|
@@ -4152,9 +4160,36 @@ const describe = (a) => a.synopsis?.text || a.intent;
|
|
|
4152
4160
|
const describeTitle = (a) => (a.synopsis?.text ? `${a.synopsis.source === 'jira' ? `${a.synopsis.key} in Jira` : `${a.synopsis.key} (summary not fetched; your words after the link)`}. Request: ${a.intent}` : a.intent);
|
|
4153
4161
|
|
|
4154
4162
|
const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
|
|
4163
|
+
// Containers: offered when Docker runs here; Claude Code and Codex only.
|
|
4164
|
+
let isolationInfo = null;
|
|
4165
|
+
const containerChoice = (form) => (isolationInfo?.available && !$('#container-field').hidden ? { isolation: form.container.checked ? 'container' : '' } : {});
|
|
4166
|
+
function syncContainerField() {
|
|
4167
|
+
const form = $('#new-form');
|
|
4168
|
+
const engine = form.engine?.value || 'claude';
|
|
4169
|
+
const ok = isolationInfo?.available && isolationInfo.engines.includes(engine);
|
|
4170
|
+
$('#container-field').hidden = !isolationInfo?.available;
|
|
4171
|
+
form.container.disabled = !ok || !isolationInfo.imageBuilt;
|
|
4172
|
+
if (!ok) form.container.checked = false;
|
|
4173
|
+
$('#container-hint').textContent = !ok ? '(Claude Code and Codex only)' : !isolationInfo.imageBuilt ? '(build the image first: ☰ → Settings → Containers)' : !isolationInfo.hasClaudeToken && engine === 'claude' ? '(add the Claude token in ☰ → Settings → Containers)' : '(Docker: it sees only its own folder)';
|
|
4174
|
+
}
|
|
4175
|
+
async function loadIsolation() {
|
|
4176
|
+
try {
|
|
4177
|
+
isolationInfo = await api('GET', '/isolation');
|
|
4178
|
+
} catch {
|
|
4179
|
+
isolationInfo = null;
|
|
4180
|
+
}
|
|
4181
|
+
const form = $('#new-form');
|
|
4182
|
+
form.container.checked = isolationInfo?.default === 'container';
|
|
4183
|
+
syncContainerField();
|
|
4184
|
+
}
|
|
4185
|
+
$('#new-form').engine?.addEventListener('change', syncContainerField);
|
|
4186
|
+
|
|
4155
4187
|
async function loadEngines() {
|
|
4156
4188
|
try {
|
|
4157
|
-
const
|
|
4189
|
+
const all = await api('GET', '/engines');
|
|
4190
|
+
// Engine names for tags and tabs, ACP agents included.
|
|
4191
|
+
for (const e of all) ENGINE_LABEL[e.id] ??= e.label;
|
|
4192
|
+
const list = all.filter((e) => e.installed && e.id !== 'claude');
|
|
4158
4193
|
// Something to know before choosing an engine (Muse Code in contributor mode, say).
|
|
4159
4194
|
const notices = Object.fromEntries(list.filter((e) => e.notice).map((e) => [e.id, e.notice]));
|
|
4160
4195
|
for (const [field, notice] of [['#engine-field', '#engine-notice'], ['#ask-engine-field', '#ask-engine-notice']]) {
|
package/public/index.html
CHANGED
|
@@ -161,6 +161,8 @@
|
|
|
161
161
|
</div>
|
|
162
162
|
<div id="term-drop" class="term-drop" hidden>Drop files to attach</div>
|
|
163
163
|
<div class="keybar" id="keybar" aria-label="Terminal keys">
|
|
164
|
+
<button data-clip-act="copy" title="Copy text from the terminal">Copy</button>
|
|
165
|
+
<button data-clip-act="paste" title="Paste into the terminal">Paste</button>
|
|
164
166
|
<button data-key="esc">Esc</button>
|
|
165
167
|
<button data-key="tab">Tab</button>
|
|
166
168
|
<button data-key="stab" title="Shift+Tab: cycle permission mode">⇧Tab</button>
|
|
@@ -245,6 +247,10 @@
|
|
|
245
247
|
<input type="checkbox" name="worktree" checked />
|
|
246
248
|
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
249
|
</label>
|
|
250
|
+
<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">
|
|
251
|
+
<input type="checkbox" name="container" />
|
|
252
|
+
Run each agent in its own container <span class="hint" id="container-hint">(Docker: it sees only its own folder)</span>
|
|
253
|
+
</label>
|
|
248
254
|
<label class="check">
|
|
249
255
|
<input type="checkbox" name="plan" checked />
|
|
250
256
|
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 +728,15 @@
|
|
|
722
728
|
<label class="check"><input type="checkbox" name="startAtLogin" /> Start AI Agent Manager when I log in</label>
|
|
723
729
|
<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
730
|
</fieldset>
|
|
731
|
+
<fieldset class="local-only" id="settings-containers">
|
|
732
|
+
<legend>Containers</legend>
|
|
733
|
+
<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>
|
|
734
|
+
<div class="row">
|
|
735
|
+
<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>
|
|
736
|
+
<div class="containers-actions"><button type="button" class="small" id="containers-build">Build image</button></div>
|
|
737
|
+
</div>
|
|
738
|
+
<label class="check"><input type="checkbox" name="containerDefault" /> Run new agents in containers by default</label>
|
|
739
|
+
</fieldset>
|
|
725
740
|
<fieldset class="local-only">
|
|
726
741
|
<legend>Jira</legend>
|
|
727
742
|
<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 +753,7 @@
|
|
|
738
753
|
</fieldset>
|
|
739
754
|
<fieldset>
|
|
740
755
|
<legend>More</legend>
|
|
741
|
-
<p class="hint settings-links"><button type="button" class="link" data-open="cleanup-btn">Clean up old agents</button
|
|
756
|
+
<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
757
|
</fieldset>
|
|
743
758
|
<p id="settings-error" class="error" hidden></p>
|
|
744
759
|
<div class="dialog-actions">
|
|
@@ -754,6 +769,8 @@
|
|
|
754
769
|
<button class="term-tab term-add" id="machine-new" title="Open another terminal">+ Terminal</button>
|
|
755
770
|
<span id="machine-title" class="hint"></span>
|
|
756
771
|
<span class="spacer"></span>
|
|
772
|
+
<button id="machine-copy" class="ghost small" title="Copy text from the terminal">Copy</button>
|
|
773
|
+
<button id="machine-paste" class="ghost small" title="Paste into the terminal">Paste</button>
|
|
757
774
|
<button id="machine-close" class="ghost small" title="Hide (the shells keep running)">Hide</button>
|
|
758
775
|
</div>
|
|
759
776
|
<div id="machine-term" class="terminal"></div>
|
package/public/machine.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// any signed-in device (over remote access it streams like an agent's shell). The shells keep
|
|
3
3
|
// running when the window is hidden; × ends one.
|
|
4
4
|
import { openStream } from '/transport.js';
|
|
5
|
+
import { openCopy, pasteInto } from '/termclip.js';
|
|
5
6
|
|
|
6
7
|
const MACHINE = '__machine__';
|
|
7
8
|
|
|
@@ -86,6 +87,9 @@ export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
|
|
|
86
87
|
shells = [...new Set([...shells, n])];
|
|
87
88
|
connect(n);
|
|
88
89
|
};
|
|
90
|
+
// Copy / Paste buttons: on a phone the terminal's text can't be selected or pasted into directly.
|
|
91
|
+
$('#machine-copy').onclick = () => xterm && openCopy(xterm, { esc, toast });
|
|
92
|
+
$('#machine-paste').onclick = () => xterm && pasteInto(xterm, { toast });
|
|
89
93
|
$('#machine-close').onclick = () => {
|
|
90
94
|
conn?.close();
|
|
91
95
|
conn = null;
|
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,18 @@ 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; }
|
|
943
|
+
|
|
944
|
+
/* Copy / Paste sheets for terminals on touch screens. */
|
|
945
|
+
.clip-sheet { width: min(760px, calc(100vw - 16px)); max-height: calc(100vh - 32px); padding: 0; display: flex; flex-direction: column; }
|
|
946
|
+
.clip-sheet:not([open]) { display: none; }
|
|
947
|
+
.clip-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
|
948
|
+
.clip-text { margin: 0; padding: 10px 12px; overflow: auto; flex: 1; min-height: 200px; max-height: 70vh; font: 12px/1.45 var(--mono, ui-monospace, monospace); white-space: pre-wrap; word-break: break-word; -webkit-user-select: text; user-select: text; background: var(--term-bg); color: #e6edf3; }
|
|
949
|
+
.clip-box { margin: 10px 12px; width: calc(100% - 24px); font: 13px var(--mono, ui-monospace, monospace); }
|
|
950
|
+
.clip-foot { display: flex; align-items: center; gap: 8px; padding: 0 12px 12px; }
|
|
951
|
+
.clip-foot .hint { flex: 1; }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Copy and paste for terminals on touch screens. xterm draws its text itself, so a phone can't
|
|
2
|
+
// long-press-select it, and its hidden input rarely gets the paste menu. Copy opens the terminal's
|
|
3
|
+
// recent output as plain text the phone can select (or Copy all); Paste reads the clipboard (the
|
|
4
|
+
// phone asks first) or, where it can't, offers a box to paste into, then sends it as one paste.
|
|
5
|
+
|
|
6
|
+
const MAX_LINES = 600;
|
|
7
|
+
|
|
8
|
+
/** The terminal's text: the last lines of its buffer, trailing blanks trimmed. */
|
|
9
|
+
export function terminalText(xterm, max = MAX_LINES) {
|
|
10
|
+
const buf = xterm.buffer.active;
|
|
11
|
+
const lines = [];
|
|
12
|
+
for (let i = Math.max(0, buf.length - max); i < buf.length; i++) {
|
|
13
|
+
const line = buf.getLine(i);
|
|
14
|
+
if (!line) continue;
|
|
15
|
+
// A wrapped line continues the one before it.
|
|
16
|
+
if (line.isWrapped && lines.length) lines[lines.length - 1] += line.translateToString(true);
|
|
17
|
+
else lines.push(line.translateToString(true));
|
|
18
|
+
}
|
|
19
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
20
|
+
return lines.join('\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sheet(html) {
|
|
24
|
+
const d = document.createElement('dialog');
|
|
25
|
+
d.className = 'clip-sheet';
|
|
26
|
+
d.innerHTML = html;
|
|
27
|
+
document.body.append(d);
|
|
28
|
+
d.addEventListener('close', () => d.remove());
|
|
29
|
+
d.showModal();
|
|
30
|
+
return d;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function writeClipboard(text) {
|
|
34
|
+
try {
|
|
35
|
+
await navigator.clipboard.writeText(text);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
// Older browsers, or no permission: copy through a selected text area.
|
|
39
|
+
const t = Object.assign(document.createElement('textarea'), { value: text });
|
|
40
|
+
t.style.cssText = 'position:fixed;opacity:0';
|
|
41
|
+
document.body.append(t);
|
|
42
|
+
t.select();
|
|
43
|
+
const ok = document.execCommand?.('copy');
|
|
44
|
+
t.remove();
|
|
45
|
+
return Boolean(ok);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Copy: the terminal's text in a sheet, selectable with the phone's own handles, and Copy all. */
|
|
50
|
+
export function openCopy(xterm, { esc, toast }) {
|
|
51
|
+
const text = terminalText(xterm);
|
|
52
|
+
const d = sheet(`<div class="clip-head"><b>Copy from the terminal</b><span class="hint">Select with your finger, or copy it all</span><span class="spacer"></span><button type="button" class="small primary" data-clip="all">Copy all</button><button type="button" class="small ghost" data-clip="close">Close</button></div>
|
|
53
|
+
<pre class="clip-text">${esc(text) || '<span class="hint">Nothing in this terminal yet.</span>'}</pre>`);
|
|
54
|
+
// Open at the end, where the latest output is.
|
|
55
|
+
const pre = d.querySelector('.clip-text');
|
|
56
|
+
pre.scrollTop = pre.scrollHeight;
|
|
57
|
+
d.addEventListener('click', async (e) => {
|
|
58
|
+
const act = e.target.closest('[data-clip]')?.dataset.clip;
|
|
59
|
+
if (act === 'close') d.close();
|
|
60
|
+
if (act === 'all') {
|
|
61
|
+
toast((await writeClipboard(text)) ? 'Copied the terminal text' : 'Copy failed: select the text and use your phone\'s Copy', 3000);
|
|
62
|
+
d.close();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Paste: the clipboard into the terminal (one paste, not submitted), or a box to paste into. */
|
|
68
|
+
export async function pasteInto(xterm, { toast }) {
|
|
69
|
+
try {
|
|
70
|
+
const text = await navigator.clipboard.readText();
|
|
71
|
+
if (text) {
|
|
72
|
+
xterm.paste(text);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Not allowed here (or refused): fall back to a box the phone can paste into.
|
|
77
|
+
}
|
|
78
|
+
const d = sheet(`<div class="clip-head"><b>Paste into the terminal</b><span class="spacer"></span><button type="button" class="small ghost" data-clip="close">Cancel</button></div>
|
|
79
|
+
<textarea class="clip-box" rows="6" placeholder="Long-press here and choose Paste"></textarea>
|
|
80
|
+
<div class="clip-foot"><span class="hint">It goes in as typed text; press Enter (⏎) in the terminal when you want it run.</span><button type="button" class="small primary" data-clip="send">Insert</button></div>`);
|
|
81
|
+
const box = d.querySelector('.clip-box');
|
|
82
|
+
box.focus();
|
|
83
|
+
d.addEventListener('click', (e) => {
|
|
84
|
+
const act = e.target.closest('[data-clip]')?.dataset.clip;
|
|
85
|
+
if (act === 'close') d.close();
|
|
86
|
+
if (act === 'send') {
|
|
87
|
+
if (box.value) xterm.paste(box.value);
|
|
88
|
+
else toast('Nothing to paste');
|
|
89
|
+
d.close();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
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
|
+
}
|