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/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 list = (await api('GET', '/engines')).filter((e) => e.installed && e.id !== 'claude');
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> · <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>
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')
@@ -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
+ }