ai-agentmanager 1.9.0 β 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +27 -3
- package/bin/acp-client.js +332 -0
- package/docker/agent.Dockerfile +15 -0
- package/docker/compose.runner.yml +22 -0
- package/docker/runner-entrypoint.sh +12 -0
- package/docker/runner.Dockerfile +31 -0
- package/docs/cloud-runner.md +62 -0
- package/package.json +2 -1
- package/public/app.js +40 -8
- package/public/index.html +14 -1
- package/public/land.js +2 -2
- package/public/menu.js +39 -0
- package/public/review.js +54 -2
- package/public/scorecard.js +68 -0
- package/public/style.css +34 -0
- package/src/backlog.js +4 -1
- package/src/cli.js +38 -2
- package/src/container.js +189 -0
- package/src/engines/acp.js +222 -0
- package/src/engines/index.js +10 -2
- package/src/fleet.js +5 -4
- package/src/hub.js +13 -3
- package/src/hublink.js +4 -2
- package/src/inbox.js +14 -0
- package/src/manager.js +90 -8
- package/src/review.js +34 -2
- package/src/scorecard.js +189 -0
- package/src/server.js +36 -2
- package/src/trust.js +1 -2
package/public/app.js
CHANGED
|
@@ -7,6 +7,7 @@ import { openStream, post, REMOTE } from '/transport.js';
|
|
|
7
7
|
import { attachBox } from '/attach.js';
|
|
8
8
|
import { initSide } from '/side.js';
|
|
9
9
|
import { addTools, download, htmlFile, initExport } from '/export.js';
|
|
10
|
+
import { badgeHtml as scoreBadge, scorecardHtml, scorecardSpeech } from '/scorecard.js';
|
|
10
11
|
import { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
|
|
11
12
|
import { initReview } from '/review.js';
|
|
12
13
|
import { initLand, prChips } from '/land.js';
|
|
@@ -123,7 +124,7 @@ $('#new-playbook').onclick = () => {
|
|
|
123
124
|
playbooksUi.open({ intent: f.intents.value.trim(), projects: f.project.value.trim() ? [f.project.value.trim()] : [], model: f.model.value.trim(), permissionMode: f.permissionMode.value });
|
|
124
125
|
};
|
|
125
126
|
$('#triggers-open').onclick = () => triggersUi.open();
|
|
126
|
-
const landUi = initLand({ api, esc, toast, agents, $, select: (id) => (showTab('agents'), select(id)), openReview: (id) => reviewUi.open(id), isOpen: () => sideTab === 'land' });
|
|
127
|
+
const landUi = initLand({ api, esc, toast, agents, $, scoreBadge, select: (id) => (showTab('agents'), select(id)), openReview: (id) => reviewUi.open(id), isOpen: () => sideTab === 'land' });
|
|
127
128
|
|
|
128
129
|
function statusOf(a) {
|
|
129
130
|
if (a.runtime.planning) return a.runtime.attention ? 'attention' : 'planning';
|
|
@@ -267,9 +268,9 @@ const INSIGHT_ICONS = { why: 'π―', what: 'π¦', how: 'π ', now: 'π' };
|
|
|
267
268
|
|
|
268
269
|
// ------------------------------------------------------------------ stakeholder documents
|
|
269
270
|
|
|
270
|
-
const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams' };
|
|
271
|
+
const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams', scorecard: 'Scorecard' };
|
|
271
272
|
// Optional extras that "Write all" leaves out.
|
|
272
|
-
const DOC_EXTRAS = new Set(['diagrams']);
|
|
273
|
+
const DOC_EXTRAS = new Set(['diagrams', 'scorecard']);
|
|
273
274
|
const DOC_TITLES = { ...DOCS, plan: 'Plan', intent: 'Intent', spec: 'Spec' };
|
|
274
275
|
// The agent's intent, spec and plan files (read from the daemon when opened; kept here by agent and kind).
|
|
275
276
|
const SPEC_KINDS = { intent: 'Intent', spec: 'Spec', plan: 'Plan' };
|
|
@@ -350,7 +351,7 @@ function docButtons(a) {
|
|
|
350
351
|
const d = a.docs?.[k];
|
|
351
352
|
const stale = d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
|
|
352
353
|
return `<button class="small doc-btn ${d?.markdown ? 'has' : ''}" data-doc="${k}" ${d?.pending ? 'disabled' : ''}
|
|
353
|
-
title="${k === 'diagrams' ? (d?.markdown ? `Open the diagrams${stale ? ' (the brief changed since)' : ''}` : 'Draw the problem and its solution for a quick grasp: architecture, design, data flow (animated) and the key interaction, diagrams only. Built from the documents already written and the agent\'s work') : d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}β¦` : label}${d?.markdown ? (stale ? ' β’' : ' β') : ''}</button>`;
|
|
354
|
+
title="${k === 'scorecard' ? (d?.markdown ? `Open the review scorecard: ${d.data?.overall ?? 'β'}/100` : 'Score the changes for review: intent, spec, scope, plan, tests and safety, with an overall readiness and where to look') : k === 'diagrams' ? (d?.markdown ? `Open the diagrams${stale ? ' (the brief changed since)' : ''}` : 'Draw the problem and its solution for a quick grasp: architecture, design, data flow (animated) and the key interaction, diagrams only. Built from the documents already written and the agent\'s work') : d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}β¦` : label}${d?.markdown ? (stale ? ' β’' : ' β') : ''}</button>`;
|
|
354
355
|
})
|
|
355
356
|
.join('')}</span>${specButtons(a)}`;
|
|
356
357
|
}
|
|
@@ -388,7 +389,7 @@ function renderDocDialog() {
|
|
|
388
389
|
else if (specKind && !d?.markdown) body.innerHTML = `<p class="hint">${docView.kind === 'intent' ? 'No intent file for this agent (agents created before 1.9 have none).' : `The agent hasn't written its ${label.toLowerCase()} yet. It writes ${docView.kind === 'spec' ? 'the spec before changing code' : 'the plan and keeps it current'}.`}</p>`;
|
|
389
390
|
else if (d?.markdown) {
|
|
390
391
|
if (body.dataset.rendered !== d.at) {
|
|
391
|
-
body.innerHTML = d.data ? humanAiHtml(d) : docHtml(d.markdown);
|
|
392
|
+
body.innerHTML = docView.kind === 'scorecard' ? scorecardHtml(d, esc) : d.data ? humanAiHtml(d) : docHtml(d.markdown);
|
|
392
393
|
body.dataset.rendered = d.at;
|
|
393
394
|
renderDiagrams(body);
|
|
394
395
|
}
|
|
@@ -399,7 +400,7 @@ function renderDocDialog() {
|
|
|
399
400
|
for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
|
|
400
401
|
// Diagrams: the whole document as one HTML file with every diagram (each also has Copy / PNG / SVG).
|
|
401
402
|
$('#doc-html').hidden = !/```mermaid/.test(d?.markdown ?? '');
|
|
402
|
-
$('#doc-listen').innerHTML = d?.markdown && docView.kind !== 'diagrams' ? listenHtml(`doc:${docView.agentId}:${docView.kind}`, { title: 'Read the document aloud' }) : '';
|
|
403
|
+
$('#doc-listen').innerHTML = d?.markdown && docView.kind !== 'diagrams' ? listenHtml(docView.kind === 'scorecard' ? `score:${docView.agentId}` : `doc:${docView.agentId}:${docView.kind}`, { title: 'Read the document aloud' }) : '';
|
|
403
404
|
$('#doc-print').hidden = plan || specKind;
|
|
404
405
|
$('#doc-regen').hidden = plan || specKind;
|
|
405
406
|
$('#doc-regen').disabled = Boolean(d?.pending);
|
|
@@ -538,6 +539,7 @@ bindListen((id) => {
|
|
|
538
539
|
if (what === 'explain') parts = [speakable(a?.explanation?.markdown)];
|
|
539
540
|
else if (what === 'doc') parts = [speakable(kind === 'explain' ? a?.explanation?.markdown : docOf(a, kind)?.markdown)];
|
|
540
541
|
else if (what === 'walk') parts = reviewUi.listenParts();
|
|
542
|
+
else if (what === 'score') parts = a?.docs?.scorecard?.data ? scorecardSpeech(a.docs.scorecard) : null;
|
|
541
543
|
else if (what === 'sidewalk') parts = sidePane?.listenParts();
|
|
542
544
|
if (!parts?.length || !play(id, parts)) toast('Nothing to read aloud here (or this browser has no speech)');
|
|
543
545
|
});
|
|
@@ -707,7 +709,7 @@ function agentItem(a) {
|
|
|
707
709
|
const idle = !isActive(a);
|
|
708
710
|
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' : ''}">
|
|
709
711
|
<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>
|
|
710
|
-
<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>
|
|
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> ` : ''}${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>
|
|
711
713
|
${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
|
|
712
714
|
<div class="meta">
|
|
713
715
|
<span class="mono">${a.id}</span>
|
|
@@ -3269,6 +3271,7 @@ function openNewDialog(mode = 'now') {
|
|
|
3269
3271
|
$('#new-error').hidden = true;
|
|
3270
3272
|
dialog.querySelector(`input[name="when"][value="${mode}"]`).checked = true;
|
|
3271
3273
|
restoreIntentSplit();
|
|
3274
|
+
loadIsolation();
|
|
3272
3275
|
syncWhen();
|
|
3273
3276
|
dialog.showModal();
|
|
3274
3277
|
dialog.querySelector('textarea').focus();
|
|
@@ -3431,6 +3434,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3431
3434
|
model: f.get('model') || undefined,
|
|
3432
3435
|
engine: f.get('engine') || undefined,
|
|
3433
3436
|
worktree: f.get('worktree') ? true : undefined,
|
|
3437
|
+
...containerChoice(e.target),
|
|
3434
3438
|
plan: Boolean(f.get('plan')),
|
|
3435
3439
|
planReview: Boolean(f.get('planReview')),
|
|
3436
3440
|
priority: Number(f.get('priority')),
|
|
@@ -3462,6 +3466,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3462
3466
|
plan: Boolean(f.get('plan')),
|
|
3463
3467
|
planReview: Boolean(f.get('planReview')),
|
|
3464
3468
|
start: Boolean(f.get('start')),
|
|
3469
|
+
...containerChoice(e.target),
|
|
3465
3470
|
});
|
|
3466
3471
|
created.forEach((a) => agents.set(a.id, a));
|
|
3467
3472
|
dialog.close();
|
|
@@ -4150,9 +4155,36 @@ const describe = (a) => a.synopsis?.text || a.intent;
|
|
|
4150
4155
|
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);
|
|
4151
4156
|
|
|
4152
4157
|
const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
|
|
4158
|
+
// Containers: offered when Docker runs here; Claude Code and Codex only.
|
|
4159
|
+
let isolationInfo = null;
|
|
4160
|
+
const containerChoice = (form) => (isolationInfo?.available && !$('#container-field').hidden ? { isolation: form.container.checked ? 'container' : '' } : {});
|
|
4161
|
+
function syncContainerField() {
|
|
4162
|
+
const form = $('#new-form');
|
|
4163
|
+
const engine = form.engine?.value || 'claude';
|
|
4164
|
+
const ok = isolationInfo?.available && isolationInfo.engines.includes(engine);
|
|
4165
|
+
$('#container-field').hidden = !isolationInfo?.available;
|
|
4166
|
+
form.container.disabled = !ok || !isolationInfo.imageBuilt;
|
|
4167
|
+
if (!ok) form.container.checked = false;
|
|
4168
|
+
$('#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)';
|
|
4169
|
+
}
|
|
4170
|
+
async function loadIsolation() {
|
|
4171
|
+
try {
|
|
4172
|
+
isolationInfo = await api('GET', '/isolation');
|
|
4173
|
+
} catch {
|
|
4174
|
+
isolationInfo = null;
|
|
4175
|
+
}
|
|
4176
|
+
const form = $('#new-form');
|
|
4177
|
+
form.container.checked = isolationInfo?.default === 'container';
|
|
4178
|
+
syncContainerField();
|
|
4179
|
+
}
|
|
4180
|
+
$('#new-form').engine?.addEventListener('change', syncContainerField);
|
|
4181
|
+
|
|
4153
4182
|
async function loadEngines() {
|
|
4154
4183
|
try {
|
|
4155
|
-
const
|
|
4184
|
+
const all = await api('GET', '/engines');
|
|
4185
|
+
// Engine names for tags and tabs, ACP agents included.
|
|
4186
|
+
for (const e of all) ENGINE_LABEL[e.id] ??= e.label;
|
|
4187
|
+
const list = all.filter((e) => e.installed && e.id !== 'claude');
|
|
4156
4188
|
// Something to know before choosing an engine (Muse Code in contributor mode, say).
|
|
4157
4189
|
const notices = Object.fromEntries(list.filter((e) => e.notice).map((e) => [e.id, e.notice]));
|
|
4158
4190
|
for (const [field, notice] of [['#engine-field', '#engine-notice'], ['#ask-engine-field', '#ask-engine-notice']]) {
|
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/land.js
CHANGED
|
@@ -25,7 +25,7 @@ export function prChips(pr, esc) {
|
|
|
25
25
|
return out.join(' ');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function initLand({ api, esc, toast, agents, $, select, openReview, isOpen }) {
|
|
28
|
+
export function initLand({ api, esc, toast, agents, $, scoreBadge = () => '', select, openReview, isOpen }) {
|
|
29
29
|
const view = $('#land-view');
|
|
30
30
|
let settings = null;
|
|
31
31
|
|
|
@@ -46,7 +46,7 @@ export function initLand({ api, esc, toast, agents, $, select, openReview, isOpe
|
|
|
46
46
|
<div class="line1"><span class="prio p${a.priority ?? 2}">P${a.priority ?? 2}</span>
|
|
47
47
|
<a class="name" href="${esc(pr.url)}" target="_blank" rel="noopener" title="${esc(pr.title ?? pr.url)}">#${esc(pr.number ?? '')} ${esc(pr.title ?? pr.url)}</a></div>
|
|
48
48
|
<div class="line2"><button class="link" data-land="goto">${esc(a.name)}</button>${a.runtime?.busy ? ' <span class="hint">working</span>' : ''}</div>
|
|
49
|
-
<div class="land-chips">${prChips(pr, esc)}</div>
|
|
49
|
+
<div class="land-chips">${scoreBadge(a.docs?.scorecard, esc)} ${prChips(pr, esc)}</div>
|
|
50
50
|
<div class="bl-actions">
|
|
51
51
|
<button class="small" data-land="review" title="Its diff, line comments for the agent, commit and push">Review</button>
|
|
52
52
|
${land === 'behind' || pr.mergeable === 'CONFLICTING' ? '<button class="small" data-land="update" title="Merge the base branch in here (when the agent is idle and its work is committed) and push; otherwise, or on conflicts, ask the agent">Update</button>' : ''}
|
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/review.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// With a walkthrough, each hunk shows its explanation and β / βΆ step through them in reading order.
|
|
4
4
|
import { cardHtml, explainButton, listenParts as walkParts, stepOf, walkthroughOf } from './walkthrough.js';
|
|
5
5
|
import { listenHtml, now as speaking, stop as stopSpeaking } from './speak.js';
|
|
6
|
+
import { scorecardHtml, scorecardOf } from './scorecard.js';
|
|
6
7
|
|
|
7
8
|
/** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
|
|
8
9
|
export function parseDiff(text) {
|
|
@@ -138,7 +139,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
138
139
|
for (const h of f.hunks) {
|
|
139
140
|
const i = w.doc ? stepOf(w.doc, f.path, h.head) : -1;
|
|
140
141
|
if (i >= 0) rows.push(`<tr class="rv-explain ${st.step === i ? 'current' : ''}" id="rv-step-${i}"><td colspan="3">${cardHtml(w.doc.data.changes[i], i, w.doc.data.changes.length, esc)}</td></tr>`);
|
|
141
|
-
rows.push(`<tr class="rv-hunk"><td colspan="3">${esc(h.head)}</td></tr>`);
|
|
142
|
+
rows.push(`<tr class="rv-hunk" data-rv-hunk="${esc(`${f.path}\t${h.head}`)}"><td colspan="3">${esc(h.head)}</td></tr>`);
|
|
142
143
|
for (const l of h.lines) {
|
|
143
144
|
const side = l.t === 'del' ? 'old' : 'new';
|
|
144
145
|
const line = side === 'old' ? l.old : l.new;
|
|
@@ -157,6 +158,32 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
157
158
|
return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<table class="rv-table">${rows.join('')}</table></div>`;
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
/** The review scorecard at the top: scores, verdict and where to look, or the button to score. */
|
|
162
|
+
function scorePanel() {
|
|
163
|
+
if (!st.data?.files.length) return '';
|
|
164
|
+
const sc = scorecardOf(agents.get(st.id), st.data.diffHash);
|
|
165
|
+
const openPr = st.data.prs.find((p) => p.state === 'OPEN');
|
|
166
|
+
const run = `<button type="button" class="small ${sc.doc && !sc.stale ? 'ghost' : 'primary'}" data-sc-run ${sc.pending ? 'disabled' : ''} title="Score the changes against the intent, spec, plan and tests, with where to look (a separate one-off call)">${sc.pending ? 'Scoringβ¦' : sc.doc ? (sc.stale ? 'Score again (changed)' : 'Score again') : 'π Score changes'}</button>`;
|
|
167
|
+
if (!sc.doc) {
|
|
168
|
+
return `<div class="rv-score-empty">${run}<span class="hint">${sc.error && !sc.pending ? `<span class="error">Couldn't score: ${esc(sc.error)}</span>` : 'Scores against the intent, spec, scope, plan, tests and safety, with an overall readiness and the few places to read.'}</span></div>`;
|
|
169
|
+
}
|
|
170
|
+
const post = openPr ? `<button type="button" class="small ghost" data-sc-post title="Post the scorecard on the pull request as a comment (posting again updates it)">${sc.doc.postedTo === openPr.url ? 'Update on PR' : 'Post to PR'}</button>` : '';
|
|
171
|
+
return `<div class="rv-score">${scorecardHtml(sc.doc, esc, { stale: sc.stale, actions: `${listenHtml(`score:${st.id}`, { title: 'Hear the scorecard' })}${post}${run}` })}</div>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Open the file of a hunk (from "where to look") and scroll to it. */
|
|
175
|
+
function gotoHunk(key) {
|
|
176
|
+
const [file] = key.split('\t');
|
|
177
|
+
st.collapsed.delete(file);
|
|
178
|
+
keepInputs();
|
|
179
|
+
render();
|
|
180
|
+
const row = [...body.querySelectorAll('[data-rv-hunk]')].find((r) => r.dataset.rvHunk === key);
|
|
181
|
+
if (!row) return toast('That part of the change has moved since it was scored');
|
|
182
|
+
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
183
|
+
row.classList.add('flash');
|
|
184
|
+
setTimeout(() => row.classList.remove('flash'), 1600);
|
|
185
|
+
}
|
|
186
|
+
|
|
160
187
|
/** Explain changes, then β n of N βΆ once there is a walkthrough. */
|
|
161
188
|
function walkNav() {
|
|
162
189
|
const w = walk();
|
|
@@ -221,6 +248,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
221
248
|
${d.files.length ? walkNav() : ''}
|
|
222
249
|
<button type="button" class="small ghost" data-rv-reload>${st.loading ? 'Loadingβ¦' : 'Reload'}</button>
|
|
223
250
|
</div>
|
|
251
|
+
${scorePanel()}
|
|
224
252
|
${walkNote()}
|
|
225
253
|
${d.busy ? '<p class="hint rv-warn">The agent is working, so the changes may still move. Reload when it is idle.</p>' : ''}
|
|
226
254
|
${d.truncated ? '<p class="hint rv-warn">The diff is large and was cut short; open a shell for the rest.</p>' : ''}
|
|
@@ -289,6 +317,29 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
289
317
|
if (t.closest('[data-rv-reload]')) return keepInputs(), reload();
|
|
290
318
|
const stepBtn = t.closest('[data-rv-step]');
|
|
291
319
|
if (stepBtn) return step(Number(stepBtn.dataset.rvStep));
|
|
320
|
+
const look = t.closest('[data-sc-look]');
|
|
321
|
+
if (look) return gotoHunk(look.dataset.scLook);
|
|
322
|
+
if (t.closest('[data-sc-run]')) {
|
|
323
|
+
keepInputs();
|
|
324
|
+
try {
|
|
325
|
+
agents.set(st.id, await api('POST', `/agents/${st.id}/docs/scorecard?background=1`));
|
|
326
|
+
render();
|
|
327
|
+
} catch (err) {
|
|
328
|
+
toast(`Error: ${err.message}`, 5000);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (t.closest('[data-sc-post]')) {
|
|
333
|
+
t.disabled = true;
|
|
334
|
+
try {
|
|
335
|
+
const r = await api('POST', `/agents/${st.id}/scorecard/post`);
|
|
336
|
+
toast(r.edited ? 'Scorecard updated on the pull request' : 'Scorecard posted on the pull request', 4000, { label: 'Open', run: () => window.open(r.url, '_blank', 'noopener') });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
toast(`Error: ${err.message}`, 6000);
|
|
339
|
+
}
|
|
340
|
+
t.disabled = false;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
292
343
|
if (t.closest('[data-rv-explain]')) {
|
|
293
344
|
keepInputs();
|
|
294
345
|
try {
|
|
@@ -442,7 +493,8 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
442
493
|
function agentChanged(a) {
|
|
443
494
|
if (!dlg.open || a.id !== st.id || !st.data) return;
|
|
444
495
|
const w = a.docs?.walkthrough;
|
|
445
|
-
const
|
|
496
|
+
const sc = a.docs?.scorecard;
|
|
497
|
+
const sig = `${w?.at}|${w?.pending}|${w?.error}|${sc?.at}|${sc?.pending}|${sc?.error}|${sc?.postedTo}`;
|
|
446
498
|
if (sig === st.walkSig) return;
|
|
447
499
|
st.walkSig = sig;
|
|
448
500
|
keepInputs();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The review scorecard on the page: an overall readiness, a bar per score with why, the acceptance
|
|
2
|
+
// criteria, and where to look. Shared by Review changes, the Scorecard document and Land's badges.
|
|
3
|
+
|
|
4
|
+
const DIMENSIONS = [
|
|
5
|
+
['intent', 'Intent'],
|
|
6
|
+
['spec', 'Spec'],
|
|
7
|
+
['tests', 'Tests'],
|
|
8
|
+
['safety', 'Safety'],
|
|
9
|
+
['scope', 'Scope'],
|
|
10
|
+
['plan', 'Plan'],
|
|
11
|
+
];
|
|
12
|
+
export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
|
|
13
|
+
const tone = (n) => (n == null ? '' : n >= 80 ? 'good' : n >= 50 ? 'mid' : 'bad');
|
|
14
|
+
const recTone = { ready: 'good', look: 'mid', not_ready: 'bad' };
|
|
15
|
+
|
|
16
|
+
/** The scorecard of an agent and whether it still matches the changes (diffHash from /changes). */
|
|
17
|
+
export function scorecardOf(agent, diffHash) {
|
|
18
|
+
const d = agent?.docs?.scorecard;
|
|
19
|
+
return { doc: d?.data ? d : null, pending: Boolean(d?.pending), error: d?.error ?? null, stale: Boolean(d?.data && diffHash && d.diffHash !== diffHash) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A small badge: "82 Β· Ready to approve". */
|
|
23
|
+
export function badgeHtml(doc, esc) {
|
|
24
|
+
if (!doc?.data) return '';
|
|
25
|
+
const sc = doc.data;
|
|
26
|
+
return `<span class="sc-badge ${recTone[sc.recommendation]}" title="Review scorecard: ${esc(RECOMMENDATION[sc.recommendation])}${sc.reasons.length ? ` (${esc(sc.reasons.join('; '))})` : ''}">${sc.overall ?? 'β'} Β· ${esc(RECOMMENDATION[sc.recommendation])}</span>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The full card. `actions` is extra HTML for the head (buttons); look items carry
|
|
31
|
+
* data-sc-look="<file>\t<head>" for the page to jump to.
|
|
32
|
+
*/
|
|
33
|
+
export function scorecardHtml(doc, esc, { stale = false, actions = '' } = {}) {
|
|
34
|
+
const sc = doc.data;
|
|
35
|
+
const rows = DIMENSIONS.map(([k, label]) => {
|
|
36
|
+
const d = sc.scores[k];
|
|
37
|
+
if (!d) return `<div class="sc-row na"><span class="sc-label">${label}</span><span class="sc-bar"></span><span class="sc-n">β</span><span class="sc-why hint">${k === 'spec' ? 'No spec written' : 'Not scored'}</span></div>`;
|
|
38
|
+
return `<div class="sc-row"><span class="sc-label">${label}</span><span class="sc-bar"><span class="${tone(d.score)}" style="width:${d.score}%"></span></span><span class="sc-n">${d.score}</span><span class="sc-why">${esc(d.why)}${d.notes.length ? ` <span class="sc-note">${esc(d.notes.join('; '))}</span>` : ''}</span></div>`;
|
|
39
|
+
}).join('');
|
|
40
|
+
const crit = sc.criteria.length
|
|
41
|
+
? `<details class="sc-crit" ${sc.criteria.some((c) => c.status !== 'met') ? 'open' : ''}><summary>Acceptance criteria: ${sc.criteria.filter((c) => c.status === 'met').length} of ${sc.criteria.length} met</summary><ul>${sc.criteria.map((c) => `<li class="${c.status}"><span class="sc-mark">${c.status === 'met' ? 'β' : c.status === 'not_met' ? 'β' : '?'}</span>${esc(c.criterion)}${c.evidence ? ` <span class="hint">${esc(c.evidence)}</span>` : ''}</li>`).join('')}</ul></details>`
|
|
42
|
+
: '';
|
|
43
|
+
const look = sc.look.length
|
|
44
|
+
? `<div class="sc-look"><b>Where to look</b><ol>${sc.look.map((l) => `<li><button type="button" class="link" data-sc-look="${esc(`${l.file}\t${l.head}`)}"><code>${esc(l.file)}</code></button> ${esc(l.reason)}</li>`).join('')}</ol></div>`
|
|
45
|
+
: '';
|
|
46
|
+
const f = sc.facts;
|
|
47
|
+
return `<div class="scorecard">
|
|
48
|
+
<div class="sc-head">
|
|
49
|
+
<span class="sc-overall ${recTone[sc.recommendation]}"><b>${sc.overall ?? 'β'}</b><small>/100</small></span>
|
|
50
|
+
<div class="sc-verdict"><b>${esc(RECOMMENDATION[sc.recommendation])}</b>${sc.reasons.length ? `<div class="hint">${esc(sc.reasons.join(' Β· '))}</div>` : ''}<div class="hint">${esc(sc.summary)}</div></div>
|
|
51
|
+
<span class="spacer"></span>${actions}
|
|
52
|
+
</div>
|
|
53
|
+
${stale ? '<p class="hint sc-stale"><b>The changes moved since this was scored.</b> Score again for the latest.</p>' : ''}
|
|
54
|
+
<div class="sc-rows">${rows}</div>
|
|
55
|
+
${crit}${look}
|
|
56
|
+
<div class="hint sc-facts">Measured: ${f.files} files (+${f.added} β${f.removed}) Β· ${f.testFiles} test files Β· CI ${esc(f.ci ?? 'n/a')} Β· checks ${f.checks.passed}β ${f.checks.failed}β${f.plan ? ` Β· plan ${f.plan.done}/${f.plan.steps}` : ''}${doc.writer ? ` Β· scored by ${esc(doc.writer)}` : ''}</div>
|
|
57
|
+
</div>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What Listen reads: the verdict, then each score with why. */
|
|
61
|
+
export function scorecardSpeech(doc) {
|
|
62
|
+
const sc = doc.data;
|
|
63
|
+
return [
|
|
64
|
+
`${RECOMMENDATION[sc.recommendation]}. Overall ${sc.overall ?? 'not scored'} out of 100. ${sc.reasons.join('. ')} ${sc.summary}`,
|
|
65
|
+
...DIMENSIONS.filter(([k]) => sc.scores[k]).map(([k, label]) => `${label}: ${sc.scores[k].score}. ${sc.scores[k].why} ${sc.scores[k].notes.join('. ')}`),
|
|
66
|
+
...(sc.look.length ? [`Where to look: ${sc.look.map((l) => `${l.file.split('/').pop()}: ${l.reason}`).join('. ')}`] : []),
|
|
67
|
+
];
|
|
68
|
+
}
|
package/public/style.css
CHANGED
|
@@ -906,3 +906,37 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
|
|
|
906
906
|
.say.on button:first-child { min-width: 30px; }
|
|
907
907
|
.explain-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
908
908
|
.sd-step.current { box-shadow: 0 0 0 2px var(--accent, #6b8cff); }
|
|
909
|
+
|
|
910
|
+
/* Review scorecard. */
|
|
911
|
+
.scorecard { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
|
|
912
|
+
.sc-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
913
|
+
.sc-overall { display: flex; align-items: baseline; gap: 2px; padding: 4px 10px; border-radius: 8px; border: 2px solid currentColor; }
|
|
914
|
+
.sc-overall b { font-size: 26px; line-height: 1; }
|
|
915
|
+
.sc-overall small { opacity: .7; }
|
|
916
|
+
.sc-verdict { min-width: 0; flex: 1 1 260px; }
|
|
917
|
+
.good { color: var(--green); } .mid { color: var(--yellow); } .bad { color: var(--red); }
|
|
918
|
+
.sc-rows { display: flex; flex-direction: column; gap: 4px; }
|
|
919
|
+
.sc-row { display: grid; grid-template-columns: 64px 140px 32px 1fr; gap: 8px; align-items: center; font-size: 13px; }
|
|
920
|
+
.sc-label { font-weight: 600; }
|
|
921
|
+
.sc-bar { height: 8px; border-radius: 4px; background: var(--panel-2); overflow: hidden; }
|
|
922
|
+
.sc-bar > span { display: block; height: 100%; background: currentColor; }
|
|
923
|
+
.sc-n { text-align: right; font-variant-numeric: tabular-nums; }
|
|
924
|
+
.sc-note { color: var(--yellow); }
|
|
925
|
+
.sc-row.na { opacity: .6; }
|
|
926
|
+
.sc-crit ul, .sc-look ol { margin: 4px 0 0; padding-left: 20px; }
|
|
927
|
+
.sc-crit li { list-style: none; margin-left: -16px; }
|
|
928
|
+
.sc-mark { display: inline-block; width: 16px; font-weight: 700; }
|
|
929
|
+
.sc-crit li.met .sc-mark { color: var(--green); } .sc-crit li.not_met .sc-mark { color: var(--red); } .sc-crit li.unverified .sc-mark { color: var(--yellow); }
|
|
930
|
+
.sc-facts { font-size: 11.5px; }
|
|
931
|
+
.sc-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; font-size: 12px; white-space: nowrap; }
|
|
932
|
+
.rv-score { margin: 0 12px 8px; }
|
|
933
|
+
.rv-score-empty { display: flex; align-items: center; gap: 10px; margin: 0 12px 8px; }
|
|
934
|
+
.rv-hunk.flash td { animation: rvflash 1.6s; }
|
|
935
|
+
@keyframes rvflash { 0%, 60% { background: var(--accent); color: var(--accent-text); } }
|
|
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')
|
|
@@ -430,9 +466,9 @@ export async function main(argv) {
|
|
|
430
466
|
|
|
431
467
|
program
|
|
432
468
|
.command('doc')
|
|
433
|
-
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only)
|
|
469
|
+
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only), walkthrough (its changes explained one by one) or scorecard (its changes scored for review)')
|
|
434
470
|
.argument('<agent>')
|
|
435
|
-
.argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, or all (background; diagrams and
|
|
471
|
+
.argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, scorecard, or all (background; diagrams, walkthrough and scorecard not included)')
|
|
436
472
|
.option('-o, --output <file>', 'also save the Markdown to this file')
|
|
437
473
|
.option('--cached', 'print the last version instead of writing a new one')
|
|
438
474
|
.option('-b, --background', 'start writing and return at once (kind "all" writes all five)')
|