ai-agentmanager 1.15.0 → 1.17.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 +12 -0
- package/README.md +11 -4
- package/package.json +1 -1
- package/public/app.js +141 -34
- package/public/index.html +1 -1
- package/public/morph.js +89 -0
- package/public/review.js +14 -2
- package/public/scorecard.js +30 -4
- package/public/style.css +9 -0
- package/src/backlog.js +2 -1
- package/src/cli.js +22 -0
- package/src/manager.js +79 -12
- package/src/scorecard.js +23 -0
- package/src/server.js +10 -0
- package/src/sessions.js +54 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.17.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
- **Sessions go both ways with the Claude desktop app.** **Open in Claude desktop** on a Claude agent (and `agentmgr desktop <agent>`) continues its conversation in the desktop app through Claude Code's `claude --desktop --resume`. The agent's session here closes first, messages for it wait until you resume it here, and **Resume** picks up what was said in the desktop app. **Import** now lists sessions started in the desktop app's Code tab (marked **Claude desktop**, with their title there) and in editors. Before, it only listed sessions started from a terminal. A session open in the desktop app is protected like one open in a terminal: close it there, or continue a copy.
|
|
6
|
+
|
|
7
|
+
## 1.16.0 (2026-11-03)
|
|
8
|
+
|
|
9
|
+
- **Faster typing and switching with many agents:** the page no longer redraws everything on every agent update. Updates are drawn once per frame, only the views they touch are redrawn (another agent's update leaves the open agent's details and terminal alone), and lists change only the cards that changed. With 40 busy agents, the page's redraw work went from about 870 ms to 35 ms per 10 seconds. Add `?perf` to the address to see how long redraws take.
|
|
10
|
+
- Switching agents with a terminal open no longer leaves the previous agent in the address (a reload opened the wrong one).
|
|
11
|
+
- **Improve before review:** the review scorecard's score shows on the agent's row (📊 75), in its Results and on backlog items in review, before you open anything. **✨ Improve** sends the agent its own scorecard to fix: the low scores with why, the acceptance criteria not met or not verified, and where a reviewer would look. It's in Results, the scorecard, Review changes, backlog items in review, the inbox, and `agentmgr improve <agent>`. When the agent marks its brief done again, the changes are scored again, and the score shows how far it moved (*82 ↑7*).
|
|
12
|
+
- **The scorecard and the AI judge's review now run when an agent marks its work done, as documented.** Neither ever did: the brief was saved before the check for "newly done", so that check never saw the change. Expect one scorecard call per finished task agent (turn it off with `"scorecard": { "auto": false }`).
|
|
13
|
+
- A reopened backlog item's note of several lines arrives as one message instead of being sent line by line. This covers Autopilot's send-backs too.
|
|
14
|
+
|
|
3
15
|
## 1.15.0 (2026-11-03)
|
|
4
16
|
|
|
5
17
|
- **Autopilot for the backlog:** finished items go on to a pull request, and merged, without you, when the review scorecard says they're ready. Per project (🛫 in the Backlog tab, `agentmgr backlog autopilot <project> off|pr|merge`), off by default. *Open PRs* commits, pushes and opens the pull request. *Merge* also merges it once checks pass, updating a branch that fell behind. Not-ready work goes back to the agent with the scorecard's reasons (twice at most). Anything else waits in the Inbox as *Autopilot held*, with **Ship it**, **Review changes** and **Send back**. An item whose pull request merges is done. **Ship it** is on every item in review.
|
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ Claude Code on its own, including its built-in ways to run work in parallel, is
|
|
|
75
75
|
- **Keeping up without reading transcripts:** a brief per agent (result, problem, fix, checks) and one inbox for every prompt and question.
|
|
76
76
|
- **From anywhere:** your phone, another computer, or several machines through the hub.
|
|
77
77
|
|
|
78
|
-
**Trying it costs little.** It runs the same `claude` you already use, with your plan and your repo's `CLAUDE.md` and skills. Sessions you started with `claude` in a terminal can be continued in it (**Import** above the agent list), and you can go back to a plain terminal
|
|
78
|
+
**Trying it costs little.** It runs the same `claude` you already use, with your plan and your repo's `CLAUDE.md` and skills. Sessions you started with `claude` in a terminal or in the Claude desktop app can be continued in it (**Import** above the agent list), and you can go back any time: to a plain terminal with `claude --resume`, or to the desktop app with **Open in Claude desktop**.
|
|
79
79
|
|
|
80
80
|
## Install
|
|
81
81
|
|
|
@@ -235,11 +235,17 @@ Every agent gets **its own worktree and its own branch** (`agent/<name>-<id>`) o
|
|
|
235
235
|
- **No upstream.** Agent branches are created without an upstream, so Claude's first `git push -u origin <branch>` sets the right one. They never accidentally track `origin/main`.
|
|
236
236
|
- **Where it shows.** The agent shows **Branched from** with the ref and commit it started at.
|
|
237
237
|
|
|
238
|
-
## Continue sessions started in a terminal
|
|
238
|
+
## Continue sessions started in a terminal or the Claude desktop app
|
|
239
239
|
|
|
240
|
-
**Import** (next to **Save now** in the agent list) lists the Claude Code sessions you started yourself
|
|
240
|
+
**Import** (next to **Save now** in the agent list) lists the Claude Code sessions you started yourself on this machine, from the last 7, 30 or 90 days: with `claude` in a terminal, in the Claude desktop app's Code tab (marked **Claude desktop**, with its title there), or in an editor. They're grouped by folder, with title, first and last ask, branch, size and last activity. Headless runs (`claude -p`, the SDK, built agents) and AI Agent Manager's own agents are left out. Each one you pick becomes an agent in the same folder that continues the same conversation (`--resume`), with the brief, inbox, PRs and usage tracked from then on.
|
|
241
241
|
|
|
242
|
-
A session still open in a terminal is marked **open in a terminal** (a running `claude` names it) or **maybe open** (a `claude` runs in that folder and the session was active minutes ago). Two places writing one conversation would mix it up, so those continue as a **copy** under a new session ID and the terminal keeps the original. For the same reason, an agent that follows a `/clear` to a new session never picks up a headless run or a session a terminal has open in its folder.
|
|
242
|
+
A session still open in a terminal or the desktop app is marked **open in a terminal** / **open in the desktop app** (a running `claude` names it) or **maybe open** (a `claude` runs in that folder and the session was active minutes ago). Two places writing one conversation would mix it up, so those continue as a **copy** under a new session ID and the terminal keeps the original. For the same reason, an agent that follows a `/clear` to a new session never picks up a headless run or a session a terminal has open in its folder.
|
|
243
|
+
|
|
244
|
+
### Open an agent in the Claude desktop app
|
|
245
|
+
|
|
246
|
+
**Open in Claude desktop** on a Claude agent (or `agentmgr desktop <agent>`) continues its conversation in the Claude desktop app, using Claude Code's own hand-off (`claude --desktop --resume`). Its session here closes first, and until you resume it here, messages for it (yours, Autopilot's, Landing's) wait instead of starting a second copy. **Resume** picks up what was said in the desktop app, since it's the same session; close it in the desktop app first. Needs macOS or Windows and Claude Code signed in with a Claude subscription; when Claude Code refuses, the reason is shown. Agents in a container, and agents moved to another machine, can't be opened there.
|
|
247
|
+
|
|
248
|
+
With Remote Control on (`"remoteControlAtStartup": true` in `~/.claude/settings.json`), running agents also show in the Claude apps while they run, as live sessions.
|
|
243
249
|
|
|
244
250
|
## Restarts and reboots
|
|
245
251
|
|
|
@@ -388,6 +394,7 @@ Each card has at most **three points of about 12 words each**, so they're easy t
|
|
|
388
394
|
- **Files and diff.** The changed files on the left with lines added and removed, and the diff file by file on the right. Click a file header to fold it; very large files start folded.
|
|
389
395
|
- **Line comments.** Click **+** next to a line (or its line number) to comment on it, and add an overall comment if you like. **Send to agent** types them into the agent as one message, each with its file, line and the code it is about, and asks it to address them. Comments not sent yet are kept in this browser.
|
|
390
396
|
- **Review scorecard.** **📊 Score changes** (it also runs by itself when the agent marks its work done) scores the changes 0-100 for **Intent**, **Spec** (each acceptance criterion met, not met or unverified, with evidence), **Tests**, **Safety**, **Scope** and **Plan**, and gives an overall readiness with a recommendation: *Ready to approve*, *Look at these first* or *Not ready*, plus **Where to look**, the few hunks worth a person's time. What the app measures caps the model: failing CI or checks, code changed without tests, plan steps not ticked, criteria not assessed. **Post to PR** puts it on the pull request as a comment (again: updates it), and Push & open PR includes it in the description. It also shows as a badge in Land. Turn the automatic run off with `"scorecard": { "auto": false }`.
|
|
397
|
+
- **Improve before you review.** The score shows before you open anything: 📊 on the agent's row, in its Results, and on backlog items in review. **✨ Improve** (in Results, the scorecard, Review changes, backlog items in review and the inbox, or `agentmgr improve <agent>`) sends the agent its own scorecard: the scores under 80 (lowest first, with why), the acceptance criteria not met or not verified, and where a reviewer would look. A backlog item in review is reopened with it. The agent shows *↻ Improving from 75*. When it next marks its brief done, the changes are scored again, also with the automatic run off, and the score shows how far it moved (*82 ↑7*).
|
|
391
398
|
- **Explain changes** explains each change one by one: a title, what it changes, why (from the request and the conversation), the risk and what to check, in the order a reviewer should read them. Each explanation sits above its hunk, and **◀ / ▶** (or **[** and **]**) step through them, opening folded files on the way. When the agent changes the code afterwards, the page says so and **Explain again** brings it up to date. From the CLI: `agentmgr doc <agent> walkthrough`.
|
|
392
399
|
- **Commit all** commits everything in the worktree, new files included, with your git name and email.
|
|
393
400
|
- **Intent, spec and plan.** Each agent keeps INTENT.md (what was asked, written by AI Agent Manager), SPEC.md (requirements and acceptance criteria, written by the agent before it changes code) and PLAN.md (steps, ticked off as it goes) next to its brief. Open them with **Intent · Spec · Plan** or in the split view's Docs; Explain, the documents and AI review read them too. `"specs": false` in config.json stops asking agents for the spec and plan.
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -9,7 +9,7 @@ import { initSide } from '/side.js';
|
|
|
9
9
|
import { openCopy, pasteInto } from '/termclip.js';
|
|
10
10
|
import { initBuilt } from '/built.js';
|
|
11
11
|
import { addTools, download, htmlFile, initExport } from '/export.js';
|
|
12
|
-
import { badgeHtml as scoreBadge, scorecardHtml, scorecardSpeech } from '/scorecard.js';
|
|
12
|
+
import { badgeHtml as scoreBadge, chipHtml as scoreChip, improveHtml, scorecardHtml, scorecardSpeech } from '/scorecard.js';
|
|
13
13
|
import { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
|
|
14
14
|
import { initReview } from '/review.js';
|
|
15
15
|
import { initLand, prChips } from '/land.js';
|
|
@@ -21,6 +21,7 @@ import { initUpdate } from '/update.js';
|
|
|
21
21
|
import { initMenu } from '/menu.js';
|
|
22
22
|
import { initCode } from '/code.js';
|
|
23
23
|
import { initMachine } from '/machine.js';
|
|
24
|
+
import { morph } from '/morph.js';
|
|
24
25
|
|
|
25
26
|
const $ = (sel) => document.querySelector(sel);
|
|
26
27
|
const agents = new Map();
|
|
@@ -158,8 +159,11 @@ function fmtTokens(n) {
|
|
|
158
159
|
|
|
159
160
|
const ioOf = (u) => (u ? (u.io ?? u.input + u.output) : 0);
|
|
160
161
|
|
|
162
|
+
// One formatter for all: n.toLocaleString() builds a new one per call, slow on every card of every redraw.
|
|
163
|
+
const num = new Intl.NumberFormat().format;
|
|
164
|
+
|
|
161
165
|
function usageTitle(u) {
|
|
162
|
-
return `I/O ${ioOf(u)
|
|
166
|
+
return `I/O ${num(ioOf(u))} (input ${num(u.input)} + output ${num(u.output)}) · cache write ${num(u.cacheCreation)} · cache read ${num(u.cacheRead)} · total incl. cache ${num(u.total)} · ${u.requests} API requests`;
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
const tokensOpen = new Set(); // agents whose token breakdown is expanded
|
|
@@ -401,9 +405,11 @@ function renderDocDialog() {
|
|
|
401
405
|
else if (specKind && !d?.loaded) body.innerHTML = '<p class="hint">Loading…</p>';
|
|
402
406
|
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>`;
|
|
403
407
|
else if (d?.markdown) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
// The scorecard's Improve button follows the agent being improved.
|
|
409
|
+
const version = docView.kind === 'scorecard' ? `${d.at}:${Boolean(a.improving)}` : d.at;
|
|
410
|
+
if (body.dataset.rendered !== version) {
|
|
411
|
+
body.innerHTML = docView.kind === 'scorecard' ? scorecardHtml(d, esc, { actions: improveHtml(a) }) : d.data ? humanAiHtml(d) : docHtml(d.markdown);
|
|
412
|
+
body.dataset.rendered = version;
|
|
407
413
|
renderDiagrams(body);
|
|
408
414
|
}
|
|
409
415
|
}
|
|
@@ -591,6 +597,12 @@ const OUTCOME = {
|
|
|
591
597
|
'in progress': ['🔄', 'In progress'],
|
|
592
598
|
};
|
|
593
599
|
|
|
600
|
+
/** The scorecard before you review: the score, Improve, and the full card. */
|
|
601
|
+
function scoreStrip(a) {
|
|
602
|
+
if (!a.docs?.scorecard?.data && !a.improving) return '';
|
|
603
|
+
return `<div class="sc-strip">${scoreBadge(a.docs?.scorecard, esc, { improving: a.improving })} ${improveHtml(a)} ${a.docs?.scorecard?.data ? '<button class="small ghost" data-doc="scorecard">Scorecard</button>' : ''}</div>`;
|
|
604
|
+
}
|
|
605
|
+
|
|
594
606
|
/** End result, status and blockers: first thing a reviewer reads. */
|
|
595
607
|
function resultsCard(a) {
|
|
596
608
|
const r = a.results;
|
|
@@ -599,6 +611,7 @@ function resultsCard(a) {
|
|
|
599
611
|
const item = (x, cls) => `<li class="${cls}">${x.url ? `<a href="${esc(x.url)}" target="_blank" rel="noopener">${md(x.text).replace(/^<p>|<\/p>\s*$/g, '')}</a>` : md(x.text).replace(/^<p>|<\/p>\s*$/g, '')}</li>`;
|
|
600
612
|
return `<div class="results ${r.outcome.replace(' ', '-')}">
|
|
601
613
|
<h4>Results <span class="outcome">${icon} ${label}</span>${r.fromAgent ? '' : ' <span class="hint">(from what AI Agent Manager can see; the agent adds its own when it next updates its brief)</span>'}</h4>
|
|
614
|
+
${scoreStrip(a)}
|
|
602
615
|
${r.blockers.length ? `<ul class="blockers">${r.blockers.map((x) => item(x, 'blocker')).join('')}</ul>` : ''}
|
|
603
616
|
${r.delivered.length ? `<ul class="delivered">${r.delivered.map((x) => item(x, 'delivered')).join('')}</ul>` : '<p class="hint">Nothing delivered yet.</p>'}
|
|
604
617
|
${r.notes ? `<div class="md">${md(r.notes)}</div>` : ''}
|
|
@@ -731,6 +744,7 @@ function agentItem(a) {
|
|
|
731
744
|
${a.prs.length ? `<span>${a.prs.length} PR</span>` : ''}
|
|
732
745
|
${a.bugs.length ? `<span>${a.bugs.length} bug</span>` : ''}
|
|
733
746
|
${a.usage?.total ? `<span title="${esc(usageTitle(a.usage))}">${fmtTokens(ioOf(a.usage))} tok</span>` : ''}
|
|
747
|
+
${scoreChip(a, esc)}
|
|
734
748
|
${(a.brief?.references ?? []).filter((r) => r.key && r.url).map((r) => `<span class="tag key-ref" title="${esc(r.title || r.url)}">${esc(r.label)}</span>`).join('')}
|
|
735
749
|
${a.brief?.summary?.total
|
|
736
750
|
? `<span class="${a.brief.summary.failed ? 'fail' : ''}" title="${esc(checksSummary(a.brief))}">✓${a.brief.summary.passed}/${a.brief.summary.total}${a.brief.summary.failed ? ` ✗${a.brief.summary.failed}` : ''}</span>`
|
|
@@ -820,14 +834,14 @@ function renderList() {
|
|
|
820
834
|
$('#counts').innerHTML = agents.size
|
|
821
835
|
? `<span title="Projects">${projects} project${projects === 1 ? '' : 's'}</span><span>${agents.size} agents</span><span>${running} running</span>
|
|
822
836
|
${waiting ? `<span class="fail">${waiting} need${waiting === 1 ? 's' : ''} input</span>` : ''}
|
|
823
|
-
<span class="spacer"></span><button class="link" id="import-btn" title="Continue Claude sessions you started in a terminal on this machine">Import</button><button class="link" id="flush-btn" title="Save session IDs, terminal scrollback and state to disk now (also automatic every 30s)">Save now</button>`
|
|
837
|
+
<span class="spacer"></span><button class="link" id="import-btn" title="Continue Claude sessions you started in a terminal or the Claude desktop app on this machine">Import</button><button class="link" id="flush-btn" title="Save session IDs, terminal scrollback and state to disk now (also automatic every 30s)">Save now</button>`
|
|
824
838
|
: '';
|
|
825
839
|
|
|
826
840
|
// Every agent a project has, also the ones a filter hides.
|
|
827
841
|
const totals = new Map();
|
|
828
842
|
for (const a of all) totals.set(a.projectInfo?.key ?? 'other', (totals.get(a.projectInfo?.key ?? 'other') ?? 0) + 1);
|
|
829
843
|
|
|
830
|
-
$('#agent-list')
|
|
844
|
+
morph($('#agent-list'), projectGroups(shown)
|
|
831
845
|
.map((g) => {
|
|
832
846
|
const total = totals.get(g.key) ?? g.agents.length;
|
|
833
847
|
const n = g.agents.length;
|
|
@@ -848,7 +862,7 @@ function renderList() {
|
|
|
848
862
|
${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
|
|
849
863
|
</li>`;
|
|
850
864
|
})
|
|
851
|
-
.join('');
|
|
865
|
+
.join(''));
|
|
852
866
|
}
|
|
853
867
|
|
|
854
868
|
// ------------------------------------------------------------------ backlog
|
|
@@ -877,7 +891,7 @@ function renderBacklog() {
|
|
|
877
891
|
}
|
|
878
892
|
const proj = Object.fromEntries(backlogState.projects.map((p) => [p.key, p]));
|
|
879
893
|
const count = (s) => backlogState.items.filter((i) => i.status === s).length;
|
|
880
|
-
$('#backlog-view')
|
|
894
|
+
morph($('#backlog-view'), `
|
|
881
895
|
<div class="counts">
|
|
882
896
|
<span>${count('queued')} queued</span><span>${count('active')} active</span>
|
|
883
897
|
${count('review') ? `<span class="fail">${count('review')} to review</span>` : '<span>0 to review</span>'}
|
|
@@ -903,7 +917,7 @@ function renderBacklog() {
|
|
|
903
917
|
})
|
|
904
918
|
.join('')}</ul>`
|
|
905
919
|
: `<div class="empty"><p>The backlog is empty.</p><p>Add asks with <b>+ Add</b> (or <b>+ New agents</b> → Queue in backlog). Agents pick them up one by one, ${backlogState.defaultLimit} at a time per project, until each is done.</p></div>`}
|
|
906
|
-
<p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p
|
|
920
|
+
<p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p>`);
|
|
907
921
|
}
|
|
908
922
|
|
|
909
923
|
const AUTOPILOT_LEVELS = { off: 'Off', pr: 'Open PRs', merge: 'Merge' };
|
|
@@ -930,13 +944,14 @@ function itemRow(it) {
|
|
|
930
944
|
<span class="mono">${it.id}</span>
|
|
931
945
|
${a ? `<span>agent <a href="#${a.id}" data-bl="open">${esc(a.name)}</a>${a.briefStatus ? ` · brief: ${esc(a.briefStatus)}` : ''}</span>` : ''}
|
|
932
946
|
${it.base ? `<span>⎇ from ${esc(it.base)}</span>` : ''}
|
|
947
|
+
${['review', 'active'].includes(it.status) ? scoreChip(agents.get(it.agentId), esc) : ''}
|
|
933
948
|
${it.doneAt ? `<span>done ${ago(it.doneAt)}</span>` : it.reviewAt ? `<span>ready ${ago(it.reviewAt)}</span>` : it.startedAt ? `<span>started ${ago(it.startedAt)}</span>` : `<span>added ${ago(it.createdAt)}</span>`}
|
|
934
949
|
</div>
|
|
935
950
|
${autopilotLine(it)}
|
|
936
951
|
${it.status === 'queued' && it.aiHold ? `<p class="hint bl-ai">🤖 Waits for <b>${esc(it.aiHold.afterTitle)}</b>${it.aiHold.reason ? `: ${esc(it.aiHold.reason)}` : ''}</p>` : ''}
|
|
937
952
|
${it.status === 'queued' && it.aiSplit ? `<div class="hint bl-ai">🤖 Could be split${it.aiSplit.reason ? ` (${esc(it.aiSplit.reason)})` : ''}:<ul>${it.aiSplit.parts.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
|
938
953
|
<button class="small" data-bl="split">Split into ${it.aiSplit.parts.length}</button> <button class="small ghost" data-bl="keep-whole">Keep whole</button></div>` : ''}
|
|
939
|
-
<div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).filter(([act]) => act !== 'ship' || !['pr', 'landing', 'merging', 'shipping'].includes(it.autopilot?.state)).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}</div>
|
|
954
|
+
<div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).filter(([act]) => act !== 'ship' || !['pr', 'landing', 'merging', 'shipping'].includes(it.autopilot?.state)).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}${it.status === 'review' ? improveHtml(agents.get(it.agentId)).replace('small primary', 'small') : ''}</div>
|
|
940
955
|
</li>`;
|
|
941
956
|
}
|
|
942
957
|
|
|
@@ -1133,7 +1148,7 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
1133
1148
|
} else if (it.kind === 'autopilot') {
|
|
1134
1149
|
body = `<p class="need-title">${esc(it.it.title)}</p>
|
|
1135
1150
|
<p class="hint">${esc(it.it.autopilot.note ?? '')}</p>
|
|
1136
|
-
<div class="need-actions"><button class="small primary" data-ib="ap-review">Review changes</button><button class="small" data-ib="ap-ship" title="Commit, push and open its pull request (and merge it, at the Merge level)">Ship it</button><button class="small" data-ib="ap-reopen">Send back…</button
|
|
1151
|
+
<div class="need-actions"><button class="small primary" data-ib="ap-review">Review changes</button><button class="small" data-ib="ap-ship" title="Commit, push and open its pull request (and merge it, at the Merge level)">Ship it</button><button class="small" data-ib="ap-reopen">Send back…</button>${improveHtml(agents.get(it.it.agentId)).replace('small primary', 'small')}</div>`;
|
|
1137
1152
|
} else if (it.kind === 'rule') {
|
|
1138
1153
|
body = `<p class="need-title">You've allowed <code>${esc(it.r.label)}</code> ${it.r.n} times in this repo. Always allow it here?</p>
|
|
1139
1154
|
<p class="hint">Agents in ${esc(a.name)} won't ask about it again. Remove it any time in Settings → Approval rules.</p>
|
|
@@ -1165,6 +1180,7 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
1165
1180
|
<div class="need-actions">
|
|
1166
1181
|
${r.missing.length ? '<button class="small primary" data-ib="review-finish" title="Send the missing items to the agent">Send back to finish</button>' : ''}
|
|
1167
1182
|
<button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
|
|
1183
|
+
${improveHtml(a).replace('small primary', 'small')}
|
|
1168
1184
|
</div>`;
|
|
1169
1185
|
} else if (it.kind === 'team') {
|
|
1170
1186
|
const t = it.t;
|
|
@@ -1248,10 +1264,12 @@ function renderInbox() {
|
|
|
1248
1264
|
if (sideTab !== 'inbox') return;
|
|
1249
1265
|
const view = $('#inbox-view');
|
|
1250
1266
|
if (view.contains(document.activeElement) && document.activeElement.dataset.draft) return; // don't yank the field being typed in
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1267
|
+
morph(
|
|
1268
|
+
view,
|
|
1269
|
+
(items.length
|
|
1270
|
+
? `<div class="counts"><span>${items.length} waiting on you</span></div><div class="needs inbox">${promptGroups(items).map(groupCard).join('')}${items.map((it) => needCard(it)).join('')}</div>`
|
|
1271
|
+
: `<div class="empty"><p>Nothing needs you right now.</p><p>Prompts waiting for an answer, questions agents ask, agents that seem stuck and agents editing the same files show up here.</p></div>`) + judgeLine(),
|
|
1272
|
+
);
|
|
1255
1273
|
}
|
|
1256
1274
|
|
|
1257
1275
|
async function onNeed(e) {
|
|
@@ -1800,7 +1818,9 @@ function renderDetail() {
|
|
|
1800
1818
|
const u = a.usage ?? { total: 0 };
|
|
1801
1819
|
const current = a.sessions.find((s) => s.id === a.sessionId);
|
|
1802
1820
|
|
|
1803
|
-
|
|
1821
|
+
const changed = morph(
|
|
1822
|
+
body,
|
|
1823
|
+
`
|
|
1804
1824
|
<div class="d-head">
|
|
1805
1825
|
<div class="d-title">
|
|
1806
1826
|
<h2>
|
|
@@ -1823,6 +1843,7 @@ function renderDetail() {
|
|
|
1823
1843
|
? ''
|
|
1824
1844
|
: `<button data-act="start" ${a.status === 'preparing' ? 'disabled' : ''}>Resume in background</button>`}
|
|
1825
1845
|
<button data-act="fresh" ${running || a.status === 'preparing' ? 'disabled' : ''} title="Start over with a new session ID (old ones are kept)">New session</button>
|
|
1846
|
+
${(a.engine ?? 'claude') === 'claude' && a.isolation !== 'container' && !a.movedTo && !REMOTE ? `<button data-act="desktop" ${a.sessionId && a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="Continue this conversation in the Claude desktop app. Its session here closes; Resume here later picks up what was said there">Open in Claude desktop</button>` : ''}
|
|
1826
1847
|
<button data-act="shell" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="A separate terminal in this agent's folder for running commands by hand">Open shell</button>
|
|
1827
1848
|
${noRepo(a) ? '' : `<button data-act="changes" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="Read its changes, comment on lines for the agent, commit and open a pull request">Review changes${a.git?.dirtyFiles ? ` <span class="count">${a.git.dirtyFiles}</span>` : ''}</button>`}
|
|
1828
1849
|
<button data-act="fork" ${a.workdir && a.git?.branch ? '' : 'disabled'} title="A new agent with this one's code and conversation, in its own worktree, to try another approach">Fork</button>
|
|
@@ -1839,6 +1860,7 @@ function renderDetail() {
|
|
|
1839
1860
|
${it.status === 'review' ? `<span class="spacer"></span><button class="small primary" data-backlog-accept="${it.id}">Accept</button> <button class="small" data-backlog-reopen="${it.id}">Reopen</button>` : ''}</div>`;
|
|
1840
1861
|
})()}
|
|
1841
1862
|
${a.error ? `<div class="error-box">${esc(a.error)}</div>` : ''}
|
|
1863
|
+
${a.desktop && !running ? `<p class="hint">Continued in the <b>Claude desktop app</b> ${ago(a.desktop.at)}. Messages for it wait here; <b>Resume</b> picks up what was said there (close it in the desktop app first).</p>` : ''}
|
|
1842
1864
|
${a.movedTo ? `<p class="hint">Moved to <b>${esc(a.movedTo.machine)}</b> ${ago(a.movedTo.at)}: it continues there with the same conversation and work. This copy stays closed; delete it when you no longer need its folder.</p>` : ''}
|
|
1843
1865
|
${a.movedFrom ? `<p class="hint">Moved here from <b>${esc(a.movedFrom.machine ?? 'another machine')}</b> ${ago(a.movedFrom.at)}, with its conversation and uncommitted work.</p>` : ''}
|
|
1844
1866
|
${!running && a.closedBy === 'cap' ? `<p class="hint">Closed ${ago(a.closedAt)} to stay under the limit on open agents (Settings), as the one idle longest. Resume it any time${a.keep ? '' : ', or mark it Keep'}.</p>` : ''}
|
|
@@ -1948,7 +1970,9 @@ function renderDetail() {
|
|
|
1948
1970
|
<h3>Notes</h3>
|
|
1949
1971
|
<textarea class="notes" data-field="notes" placeholder="Anything worth remembering about this agent">${esc(drafts.notes ?? a.notes ?? '')}</textarea>
|
|
1950
1972
|
</div>
|
|
1951
|
-
</div
|
|
1973
|
+
</div>`,
|
|
1974
|
+
);
|
|
1975
|
+
if (!changed) return;
|
|
1952
1976
|
|
|
1953
1977
|
renderDiagrams(body);
|
|
1954
1978
|
markOverflow(body);
|
|
@@ -1963,22 +1987,77 @@ function renderDetail() {
|
|
|
1963
1987
|
}
|
|
1964
1988
|
}
|
|
1965
1989
|
|
|
1990
|
+
const VIEWS = {
|
|
1991
|
+
list: () => renderList(),
|
|
1992
|
+
backlog: () => renderBacklog(),
|
|
1993
|
+
land: () => landUi.render(),
|
|
1994
|
+
inbox: () => renderInbox(),
|
|
1995
|
+
race: () => renderRace(),
|
|
1996
|
+
team: () => renderTeam(),
|
|
1997
|
+
doc: () => renderDocDialog(),
|
|
1998
|
+
detail: () => renderDetail(),
|
|
1999
|
+
overlay: () => term.syncOverlay(),
|
|
2000
|
+
};
|
|
2001
|
+
const ALL_VIEWS = Object.keys(VIEWS);
|
|
2002
|
+
|
|
1966
2003
|
function render() {
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2004
|
+
dirty.clear();
|
|
2005
|
+
perf.draw(ALL_VIEWS);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Live updates come many times a second when many agents work. They mark the views they touch,
|
|
2009
|
+
// and those are drawn once in the next frame, so typing never waits behind a run of redraws.
|
|
2010
|
+
const dirty = new Set();
|
|
2011
|
+
let drawQueued = false;
|
|
2012
|
+
|
|
2013
|
+
function invalidate(views = ALL_VIEWS) {
|
|
2014
|
+
for (const v of views) dirty.add(v);
|
|
2015
|
+
if (drawQueued) return;
|
|
2016
|
+
drawQueued = true;
|
|
2017
|
+
const draw = () => {
|
|
2018
|
+
if (!drawQueued) return;
|
|
2019
|
+
drawQueued = false;
|
|
2020
|
+
const views = ALL_VIEWS.filter((v) => dirty.has(v));
|
|
2021
|
+
dirty.clear();
|
|
2022
|
+
perf.draw(views);
|
|
2023
|
+
};
|
|
2024
|
+
requestAnimationFrame(draw);
|
|
2025
|
+
// A background tab gets no frames; it still keeps its title ("(2) needs input") current.
|
|
2026
|
+
setTimeout(draw, 500);
|
|
1976
2027
|
}
|
|
1977
2028
|
|
|
2029
|
+
// ?perf: how long redraws take, in a corner of the page.
|
|
2030
|
+
const perf = {
|
|
2031
|
+
on: new URLSearchParams(location.search).has('perf'),
|
|
2032
|
+
runs: [], // [at, ms, { view: ms }] over the last 5 seconds
|
|
2033
|
+
draw(views) {
|
|
2034
|
+
if (!this.on) {
|
|
2035
|
+
for (const v of views) VIEWS[v]();
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
const each = {};
|
|
2039
|
+
const t = performance.now();
|
|
2040
|
+
for (const v of views) {
|
|
2041
|
+
const t0 = performance.now();
|
|
2042
|
+
VIEWS[v]();
|
|
2043
|
+
each[v] = performance.now() - t0;
|
|
2044
|
+
}
|
|
2045
|
+
const now = performance.now();
|
|
2046
|
+
this.runs = [...this.runs.filter(([at]) => now - at < 5000), [now, now - t, each]];
|
|
2047
|
+
const ms = this.runs.map(([, d]) => d).sort((x, y) => x - y);
|
|
2048
|
+
const by = {};
|
|
2049
|
+
for (const [, , e] of this.runs) for (const [v, d] of Object.entries(e)) by[v] = (by[v] ?? 0) + d;
|
|
2050
|
+
const top = Object.entries(by).sort((x, y) => y[1] - x[1]).slice(0, 3).map(([v, d]) => `${v} ${Math.round((d / ms.reduce((x, y) => x + y, 0)) * 100)}%`);
|
|
2051
|
+
const el = $('#perf') ?? document.body.appendChild(Object.assign(document.createElement('div'), { id: 'perf', className: 'perf' }));
|
|
2052
|
+
el.textContent = `redraw ${ms[ms.length >> 1].toFixed(1)} ms · p95 ${ms[Math.floor(ms.length * 0.95)].toFixed(1)} ms · ${(ms.length / 5).toFixed(1)}/s · ${top.join(' ')}`;
|
|
2053
|
+
},
|
|
2054
|
+
};
|
|
2055
|
+
|
|
1978
2056
|
function select(id) {
|
|
1979
2057
|
selectedId = id;
|
|
1980
|
-
|
|
2058
|
+
// Closing the terminal puts its agent in the address; the one switched to goes there after.
|
|
1981
2059
|
if (term.agentId && term.agentId !== id) term.close();
|
|
2060
|
+
history.replaceState(null, '', id ? `#${id}` : '#');
|
|
1982
2061
|
render();
|
|
1983
2062
|
}
|
|
1984
2063
|
|
|
@@ -2083,7 +2162,8 @@ const term = {
|
|
|
2083
2162
|
} else if (msg.t === 'error') {
|
|
2084
2163
|
toast(`Terminal unavailable: ${msg.error}`, 5000);
|
|
2085
2164
|
} else if (msg.t === 'o') {
|
|
2086
|
-
|
|
2165
|
+
// Output is most of what arrives (every keystroke's echo): nothing else on the page changes.
|
|
2166
|
+
return this.xterm.write(msg.d);
|
|
2087
2167
|
} else if (msg.t === 'exit') {
|
|
2088
2168
|
if (isShell(this.kind)) this.shellExited = true;
|
|
2089
2169
|
else this.xterm.write(`\r\n\x1b[2m[claude exited with code ${msg.code}; session kept]\x1b[0m\r\n`);
|
|
@@ -2687,7 +2767,8 @@ function renderImport() {
|
|
|
2687
2767
|
(s) => `<label class="import-row ${s.exists ? '' : 'off'}">
|
|
2688
2768
|
<input type="checkbox" data-sid="${s.sessionId}" ${importPicked.has(s.sessionId) ? 'checked' : ''} ${s.exists ? '' : 'disabled'}>
|
|
2689
2769
|
<span class="import-main"><b>${esc(s.title || s.firstAsk.slice(0, 80))}</b>
|
|
2690
|
-
${s.
|
|
2770
|
+
${s.source === 'desktop' ? `<span class="tag" title="Started in the Claude desktop app${s.archived ? '; archived there' : ''}">Claude desktop${s.archived ? ' · archived' : ''}</span>` : s.source === 'editor' ? '<span class="tag" title="Started in an editor (VS Code, JetBrains)">editor</span>' : ''}
|
|
2771
|
+
${s.inUse ? `<span class="tag warn" title="${s.inUse === 'open' ? 'A claude process has this session open' : 'A claude process runs in this folder and the session was active minutes ago'}">${s.inUse === 'open' ? 'open' : 'maybe open'} ${s.source === 'desktop' ? 'in the desktop app' : 'in a terminal'}</span>` : ''}
|
|
2691
2772
|
<span class="hint">${s.title ? `${esc(s.firstAsk.slice(0, 120))}${s.lastAsk ? ` … last: ${esc(s.lastAsk.slice(0, 80))}` : ''}` : s.lastAsk ? `last: ${esc(s.lastAsk.slice(0, 100))}` : ''}</span>
|
|
2692
2773
|
<span class="hint">${ago(s.lastActivity)}${s.branch ? ` · ⎇ ${esc(s.branch)}` : ''} · ${Math.max(1, Math.round(s.bytes / 1024))} KB · <code>${s.sessionId.slice(0, 8)}</code></span>
|
|
2693
2774
|
</span></label>`,
|
|
@@ -2695,13 +2776,13 @@ function renderImport() {
|
|
|
2695
2776
|
.join('')}</div>`,
|
|
2696
2777
|
)
|
|
2697
2778
|
.join('')
|
|
2698
|
-
: `<p class="hint">${importSessions.length ? 'Nothing matches.' : 'No Claude sessions started in a terminal in this period (AI Agent Manager\'s own agents are left out).'}</p>`;
|
|
2779
|
+
: `<p class="hint">${importSessions.length ? 'Nothing matches.' : 'No Claude sessions started in a terminal or the Claude desktop app in this period (AI Agent Manager\'s own agents are left out).'}</p>`;
|
|
2699
2780
|
const picked = importSessions.filter((s) => importPicked.has(s.sessionId));
|
|
2700
2781
|
const open = picked.filter((s) => s.inUse).length;
|
|
2701
2782
|
Object.assign($('#import-go'), {
|
|
2702
2783
|
disabled: !picked.length,
|
|
2703
2784
|
textContent: picked.length ? `Continue ${picked.length} session${picked.length > 1 ? 's' : ''}${open ? ` (${open} as a copy)` : ''}` : 'Continue',
|
|
2704
|
-
title: open ? 'Sessions still open in a terminal continue as a copy, so the
|
|
2785
|
+
title: open ? 'Sessions still open in a terminal or the desktop app continue as a copy, so the original stays there' : '',
|
|
2705
2786
|
});
|
|
2706
2787
|
}
|
|
2707
2788
|
$('#import-list').addEventListener('change', (e) => {
|
|
@@ -3130,6 +3211,16 @@ $('#counts').addEventListener('click', (e) => {
|
|
|
3130
3211
|
});
|
|
3131
3212
|
});
|
|
3132
3213
|
|
|
3214
|
+
// Improve (agent details, scorecard, backlog, inbox): its scorecard goes back to the agent to fix.
|
|
3215
|
+
document.addEventListener('click', (e) => {
|
|
3216
|
+
const btn = e.target.closest('[data-sc-improve]');
|
|
3217
|
+
if (!btn) return;
|
|
3218
|
+
e.preventDefault();
|
|
3219
|
+
e.stopPropagation();
|
|
3220
|
+
btn.disabled = true;
|
|
3221
|
+
act(async () => upsert(await api('POST', `/agents/${btn.dataset.scImprove}/scorecard/improve`)), 'Sent to the agent to improve; scored again when it is done').finally(() => (btn.disabled = false));
|
|
3222
|
+
});
|
|
3223
|
+
|
|
3133
3224
|
$('#agent-list').addEventListener('click', (e) => {
|
|
3134
3225
|
const rename = e.target.closest('[data-rename]');
|
|
3135
3226
|
if (rename) {
|
|
@@ -3323,6 +3414,14 @@ $('#detail-body').addEventListener('click', async (e) => {
|
|
|
3323
3414
|
term.open(a.id);
|
|
3324
3415
|
});
|
|
3325
3416
|
break;
|
|
3417
|
+
case 'desktop':
|
|
3418
|
+
btn.disabled = true;
|
|
3419
|
+
btn.textContent = 'Opening…';
|
|
3420
|
+
act(async () => upsert(await api('POST', `/agents/${a.id}/desktop`)), 'Opened in the Claude desktop app; Resume here picks it back up').finally(() => {
|
|
3421
|
+
btn.disabled = false;
|
|
3422
|
+
btn.textContent = 'Open in Claude desktop';
|
|
3423
|
+
});
|
|
3424
|
+
break;
|
|
3326
3425
|
case 'stop':
|
|
3327
3426
|
btn.disabled = true;
|
|
3328
3427
|
act(async () => upsert(await api('POST', `/agents/${a.id}/stop`)), 'Session closed; the ID is kept for resuming');
|
|
@@ -3759,10 +3858,18 @@ function handleEvent(msg) {
|
|
|
3759
3858
|
}
|
|
3760
3859
|
openTermOnLoad = false;
|
|
3761
3860
|
} else if (msg.t === 'agent') {
|
|
3762
|
-
|
|
3861
|
+
const id = msg.agent.id;
|
|
3862
|
+
agents.set(id, msg.agent);
|
|
3763
3863
|
watchDocs(msg.agent);
|
|
3764
3864
|
agentChanged(msg.agent);
|
|
3765
3865
|
if (msg.agent.builds) builtUi.sessionChanged(msg.agent);
|
|
3866
|
+
// The detail, terminal and document views show one agent; another agent's update leaves them be.
|
|
3867
|
+
return invalidate([
|
|
3868
|
+
'list', 'backlog', 'land', 'inbox', 'race', 'team',
|
|
3869
|
+
...(docView?.agentId === id ? ['doc'] : []),
|
|
3870
|
+
...(id === selectedId || msg.agent.forkOf?.agentId === selectedId ? ['detail'] : []),
|
|
3871
|
+
...(id === term.agentId ? ['overlay'] : []),
|
|
3872
|
+
]);
|
|
3766
3873
|
} else if (msg.t === 'removed') {
|
|
3767
3874
|
agents.delete(msg.id);
|
|
3768
3875
|
if (selectedId === msg.id) select(null);
|
|
@@ -3787,7 +3894,7 @@ function handleEvent(msg) {
|
|
|
3787
3894
|
projectsState = msg.projects;
|
|
3788
3895
|
renderProjectOptions();
|
|
3789
3896
|
}
|
|
3790
|
-
|
|
3897
|
+
invalidate();
|
|
3791
3898
|
}
|
|
3792
3899
|
|
|
3793
3900
|
function connectEvents() {
|
package/public/index.html
CHANGED
|
@@ -429,7 +429,7 @@
|
|
|
429
429
|
<button type="button" id="import-close" class="small ghost">Close</button>
|
|
430
430
|
</div>
|
|
431
431
|
<div class="import-body">
|
|
432
|
-
<p class="hint">Sessions you started with <code>claude</code> in a terminal on this machine. Each one you pick becomes an agent in the same folder that continues the same conversation, with the brief, inbox, PRs and usage tracked here.</p>
|
|
432
|
+
<p class="hint">Sessions you started with <code>claude</code> in a terminal, or in the Claude desktop app, on this machine. Each one you pick becomes an agent in the same folder that continues the same conversation, with the brief, inbox, PRs and usage tracked here.</p>
|
|
433
433
|
<input id="import-filter" placeholder="Filter by folder, title or what you asked" autocomplete="off">
|
|
434
434
|
<div id="import-list" class="import-list"></div>
|
|
435
435
|
</div>
|
package/public/morph.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Live views are re-rendered as HTML strings many times a second (every agent update). Swapping
|
|
2
|
+
// innerHTML each time rebuilds every element, which the browser must restyle, lay out and paint,
|
|
3
|
+
// and it drops focus, selection and a click in progress. morph() changes only what differs: the
|
|
4
|
+
// same element stays in place when its tag (and data-id / id, when present) still match.
|
|
5
|
+
|
|
6
|
+
const FIELDS = new Set(['INPUT', 'SELECT', 'TEXTAREA']);
|
|
7
|
+
const keyOf = (n) => (n.nodeType === 1 ? n.getAttribute('data-id') ?? n.id ?? '' : '');
|
|
8
|
+
|
|
9
|
+
function sameKind(a, b) {
|
|
10
|
+
return a.nodeType === b.nodeType && a.nodeName === b.nodeName && keyOf(a) === keyOf(b);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function syncAttrs(from, to) {
|
|
14
|
+
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name)) from.removeAttribute(name);
|
|
15
|
+
for (const { name, value } of to.attributes) if (from.getAttribute(name) !== value) from.setAttribute(name, value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// What a fresh element would show; the field being typed in keeps what's typed.
|
|
19
|
+
function syncField(from, to) {
|
|
20
|
+
if (from.type === 'checkbox' || from.type === 'radio') from.checked = to.checked;
|
|
21
|
+
else if (from !== document.activeElement && from.value !== to.value) from.value = to.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function morphNode(from, to) {
|
|
25
|
+
// Native and fast: most of a list is unchanged. Its fields may still show something else.
|
|
26
|
+
if (from.isEqualNode(to)) {
|
|
27
|
+
if (from.nodeType !== 1) return;
|
|
28
|
+
if (FIELDS.has(from.nodeName)) return syncField(from, to);
|
|
29
|
+
const mine = from.querySelectorAll('input, select, textarea');
|
|
30
|
+
if (mine.length) {
|
|
31
|
+
const theirs = to.querySelectorAll('input, select, textarea');
|
|
32
|
+
mine.forEach((f, i) => syncField(f, theirs[i]));
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (from.nodeType !== 1) {
|
|
37
|
+
if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// A diagram drawn into its slot (renderDiagrams) stays while its source is the same.
|
|
41
|
+
if (from.dataset.filled && from.dataset.mermaid === to.dataset.mermaid) return;
|
|
42
|
+
syncAttrs(from, to);
|
|
43
|
+
if (from.nodeName === 'TEXTAREA') {
|
|
44
|
+
if (from.defaultValue !== to.defaultValue) from.defaultValue = to.defaultValue;
|
|
45
|
+
syncField(from, to);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
morphChildren(from, to);
|
|
49
|
+
if (from.nodeName === 'INPUT' || from.nodeName === 'SELECT') syncField(from, to);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function morphChildren(from, to) {
|
|
53
|
+
let a = from.firstChild;
|
|
54
|
+
let b = to.firstChild;
|
|
55
|
+
while (b) {
|
|
56
|
+
const next = b.nextSibling;
|
|
57
|
+
if (a && sameKind(a, b)) {
|
|
58
|
+
morphNode(a, b);
|
|
59
|
+
a = a.nextSibling;
|
|
60
|
+
} else {
|
|
61
|
+
// A keyed element further on (a card that moved up): bring it here instead of rebuilding it.
|
|
62
|
+
const key = keyOf(b);
|
|
63
|
+
let found = null;
|
|
64
|
+
if (key) for (let c = a?.nextSibling; c; c = c.nextSibling) if (sameKind(c, b)) { found = c; break; }
|
|
65
|
+
if (found) {
|
|
66
|
+
from.insertBefore(found, a);
|
|
67
|
+
morphNode(found, b);
|
|
68
|
+
} else from.insertBefore(b, a); // moves b out of the template
|
|
69
|
+
}
|
|
70
|
+
b = next;
|
|
71
|
+
}
|
|
72
|
+
while (a) {
|
|
73
|
+
const next = a.nextSibling;
|
|
74
|
+
a.remove();
|
|
75
|
+
a = next;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const tpl = document.createElement('template');
|
|
80
|
+
|
|
81
|
+
/** Make el's contents match html, touching only what changed. False when nothing did. */
|
|
82
|
+
export function morph(el, html) {
|
|
83
|
+
if (el.__html === html && el.firstChild) return false;
|
|
84
|
+
el.__html = html;
|
|
85
|
+
tpl.innerHTML = html;
|
|
86
|
+
morphChildren(el, tpl.content);
|
|
87
|
+
tpl.innerHTML = '';
|
|
88
|
+
return true;
|
|
89
|
+
}
|
package/public/review.js
CHANGED
|
@@ -3,7 +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
|
+
import { improveHtml, scorecardHtml, scorecardOf } from './scorecard.js';
|
|
7
7
|
|
|
8
8
|
/** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
|
|
9
9
|
export function parseDiff(text) {
|
|
@@ -168,7 +168,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
168
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
169
|
}
|
|
170
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>`;
|
|
171
|
+
return `<div class="rv-score">${scorecardHtml(sc.doc, esc, { stale: sc.stale, actions: `${listenHtml(`score:${st.id}`, { title: 'Hear the scorecard' })}${sc.stale ? '' : improveHtml(agents.get(st.id))}${post}${run}` })}</div>`;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/** Open the file of a hunk (from "where to look") and scroll to it. */
|
|
@@ -329,6 +329,18 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
329
329
|
}
|
|
330
330
|
return;
|
|
331
331
|
}
|
|
332
|
+
if (t.closest('[data-sc-improve]')) {
|
|
333
|
+
e.stopPropagation(); // the page's own Improve handler is for elsewhere
|
|
334
|
+
keepInputs();
|
|
335
|
+
try {
|
|
336
|
+
agents.set(st.id, await api('POST', `/agents/${st.id}/scorecard/improve`));
|
|
337
|
+
toast('Sent to the agent to improve; scored again when it is done', 5000);
|
|
338
|
+
render();
|
|
339
|
+
} catch (err) {
|
|
340
|
+
toast(`Error: ${err.message}`, 5000);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
332
344
|
if (t.closest('[data-sc-post]')) {
|
|
333
345
|
t.disabled = true;
|
|
334
346
|
try {
|
package/public/scorecard.js
CHANGED
|
@@ -19,11 +19,37 @@ export function scorecardOf(agent, diffHash) {
|
|
|
19
19
|
return { doc: d?.data ? d : null, pending: Boolean(d?.pending), error: d?.error ?? null, stale: Boolean(d?.data && diffHash && d.diffHash !== diffHash) };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/** How far Improve moved the score: "↑18" (from 64), or nothing. */
|
|
23
|
+
const delta = (doc) => {
|
|
24
|
+
const from = doc.improvedFrom;
|
|
25
|
+
const to = doc.data?.overall;
|
|
26
|
+
if (from == null || to == null || from === to) return '';
|
|
27
|
+
return `<span class="sc-delta ${to > from ? 'up' : 'down'}" title="After Improve: was ${from}">${to > from ? '↑' : '↓'}${Math.abs(to - from)}</span>`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A small badge: "82 · Ready to approve". Pass the agent's `improving` to show it being improved. */
|
|
31
|
+
export function badgeHtml(doc, esc, { improving = null } = {}) {
|
|
32
|
+
if (improving) return `<span class="sc-badge" title="Sent back to improve its ${improving.from ?? '—'}/100; scored again when it is done">↻ Improving from ${improving.from ?? '—'}</span>`;
|
|
24
33
|
if (!doc?.data) return '';
|
|
25
34
|
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>`;
|
|
35
|
+
return `<span class="sc-badge ${recTone[sc.recommendation]}" title="Review scorecard: ${esc(RECOMMENDATION[sc.recommendation])}${sc.reasons.length ? ` (${esc(sc.reasons.join('; '))})` : ''}">${sc.overall ?? '—'}${delta(doc)} · ${esc(RECOMMENDATION[sc.recommendation])}</span>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Just the number, for a list row: "82", "82↑18" or "↻" while being improved. */
|
|
39
|
+
export function chipHtml(agent, esc) {
|
|
40
|
+
const doc = agent?.docs?.scorecard;
|
|
41
|
+
if (agent?.improving) return `<span class="sc-chip" title="Being improved from ${agent.improving.from ?? '—'}/100">↻ ${agent.improving.from ?? ''}</span>`;
|
|
42
|
+
if (!doc?.data) return '';
|
|
43
|
+
return `<span class="sc-chip ${recTone[doc.data.recommendation]}" title="Review scorecard: ${doc.data.overall ?? '—'}/100, ${esc(RECOMMENDATION[doc.data.recommendation])}">📊 ${doc.data.overall ?? '—'}${delta(doc)}</span>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Improve: send the agent its scorecard to fix before review (POST /agents/:id/scorecard/improve). */
|
|
47
|
+
export function improveHtml(agent) {
|
|
48
|
+
const doc = agent?.docs?.scorecard;
|
|
49
|
+
if (!doc?.data) return '';
|
|
50
|
+
if (agent.improving) return '<button type="button" class="small ghost" disabled title="Scored again when the agent updates its brief">Improving…</button>';
|
|
51
|
+
const ready = doc.data.recommendation === 'ready';
|
|
52
|
+
return `<button type="button" class="small ${ready ? 'ghost' : 'primary'}" data-sc-improve="${agent.id}" title="Send the agent its low scores, unmet criteria and where a reviewer would look, to fix before you review; it is scored again when it is done">✨ Improve</button>`;
|
|
27
53
|
}
|
|
28
54
|
|
|
29
55
|
/**
|
|
@@ -46,7 +72,7 @@ export function scorecardHtml(doc, esc, { stale = false, actions = '' } = {}) {
|
|
|
46
72
|
const f = sc.facts;
|
|
47
73
|
return `<div class="scorecard">
|
|
48
74
|
<div class="sc-head">
|
|
49
|
-
<span class="sc-overall ${recTone[sc.recommendation]}"><b>${sc.overall ?? '—'}</b><small>/100</small
|
|
75
|
+
<span class="sc-overall ${recTone[sc.recommendation]}"><b>${sc.overall ?? '—'}</b><small>/100</small>${delta(doc)}</span>
|
|
50
76
|
<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
77
|
<span class="spacer"></span>${actions}
|
|
52
78
|
</div>
|
package/public/style.css
CHANGED
|
@@ -934,6 +934,12 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
|
|
|
934
934
|
.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); }
|
|
935
935
|
.sc-facts { font-size: 11.5px; }
|
|
936
936
|
.sc-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; font-size: 12px; white-space: nowrap; }
|
|
937
|
+
/* Scorecard before review: the number on list rows, how far Improve moved it, and the strip in Results. */
|
|
938
|
+
.sc-chip { white-space: nowrap; }
|
|
939
|
+
.sc-delta { margin-left: 3px; font-size: 11px; font-weight: 600; }
|
|
940
|
+
.sc-delta.up { color: var(--green); } .sc-delta.down { color: var(--red); }
|
|
941
|
+
.sc-overall .sc-delta { align-self: center; }
|
|
942
|
+
.sc-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 8px; }
|
|
937
943
|
.rv-score { margin: 0 12px 8px; }
|
|
938
944
|
.rv-score-empty { display: flex; align-items: center; gap: 10px; margin: 0 12px 8px; }
|
|
939
945
|
.rv-hunk.flash td { animation: rvflash 1.6s; }
|
|
@@ -989,3 +995,6 @@ body.remote .settings-links .local-only { display: none; }
|
|
|
989
995
|
.rules-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
990
996
|
.rules-list li button { margin-left: auto; }
|
|
991
997
|
.need.group { border-style: dashed; }
|
|
998
|
+
|
|
999
|
+
/* ?perf: redraw timings */
|
|
1000
|
+
.perf { position: fixed; right: 8px; bottom: 8px; z-index: 1000; padding: 3px 8px; border-radius: 6px; background: var(--panel, #1a1d27); color: var(--muted); font: 11px ui-monospace, Menlo, monospace; pointer-events: none; opacity: 0.9; }
|
package/src/backlog.js
CHANGED
|
@@ -275,7 +275,8 @@ export class Backlog extends EventEmitter {
|
|
|
275
275
|
Promise.resolve(this.manager.start(agentId))
|
|
276
276
|
.then(() => new Promise((r) => setTimeout(r, 1500)))
|
|
277
277
|
.then(() => {
|
|
278
|
-
|
|
278
|
+
// Bracketed paste keeps a note of several lines one message (each line would submit).
|
|
279
|
+
this.manager.write(agentId, message.includes('\n') ? `\x1b[200~${message}\x1b[201~` : message);
|
|
279
280
|
setTimeout(() => this.manager.write(agentId, '\r'), 200);
|
|
280
281
|
})
|
|
281
282
|
.catch(() => {});
|
package/src/cli.js
CHANGED
|
@@ -957,6 +957,28 @@ export async function main(argv) {
|
|
|
957
957
|
console.log(`${green('✓')} answer queued for ${a.name}; it is typed into the session when the agent is idle`);
|
|
958
958
|
});
|
|
959
959
|
|
|
960
|
+
program
|
|
961
|
+
.command('improve')
|
|
962
|
+
.description("Before review: send an agent its scorecard's low scores, unmet criteria and where a reviewer would look, to fix; it is scored again when done")
|
|
963
|
+
.argument('<agent>')
|
|
964
|
+
.action(async (ref) => {
|
|
965
|
+
await daemon();
|
|
966
|
+
const a = await resolveAgent(ref);
|
|
967
|
+
await api('POST', `/agents/${a.id}/scorecard/improve`);
|
|
968
|
+
console.log(`${green('✓')} sent ${a.name} its scorecard (${a.docs.scorecard.data.overall ?? '—'}/100) to improve; scored again when it updates its brief`);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
program
|
|
972
|
+
.command('desktop')
|
|
973
|
+
.description('Continue a Claude agent in the Claude desktop app (its session here closes; resume it here to pick up what was said there)')
|
|
974
|
+
.argument('<agent>')
|
|
975
|
+
.action(async (ref) => {
|
|
976
|
+
await daemon();
|
|
977
|
+
const a = await resolveAgent(ref);
|
|
978
|
+
await api('POST', `/agents/${a.id}/desktop`);
|
|
979
|
+
console.log(`${green('✓')} ${a.name} opened in the Claude desktop app; continue it here with: agentmgr start ${a.id}`);
|
|
980
|
+
});
|
|
981
|
+
|
|
960
982
|
program
|
|
961
983
|
.command('tell')
|
|
962
984
|
.description("Send a message into an agent's session (typed in when it is idle; resumes it if closed)")
|
package/src/manager.js
CHANGED
|
@@ -101,7 +101,7 @@ import { spawn } from 'node:child_process';
|
|
|
101
101
|
import * as git from './git.js';
|
|
102
102
|
import { parseDiff } from '../public/review.js';
|
|
103
103
|
import { issueSummary, leadingIssue, wordsAfterLink } from './jira.js';
|
|
104
|
-
import { combine, facts as scoreFacts, scorecardMarkdown, scorecardPrompt } from './scorecard.js';
|
|
104
|
+
import { combine, facts as scoreFacts, improveMessage, scorecardMarkdown, scorecardPrompt } from './scorecard.js';
|
|
105
105
|
import { CONTAINER_ENGINES, claudeToken, containerName, containerRunning, engineMounts, gitCommonDir, removeContainer, runArgs, stopContainer } from './container.js';
|
|
106
106
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
107
107
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
@@ -955,6 +955,7 @@ export class AgentManager extends EventEmitter {
|
|
|
955
955
|
if (!agent.workdir || !fs.existsSync(agent.workdir)) {
|
|
956
956
|
throw Object.assign(new Error(`Working folder ${agent.workdir} no longer exists`), { status: 409 });
|
|
957
957
|
}
|
|
958
|
+
if (agent.desktop) this.patch(agent.id, { desktop: null }); // continued here again
|
|
958
959
|
|
|
959
960
|
if (sessionId) {
|
|
960
961
|
if (!agent.sessions.some((s) => s.id === sessionId)) {
|
|
@@ -1763,9 +1764,52 @@ export class AgentManager extends EventEmitter {
|
|
|
1763
1764
|
return this.view(this.store.get(agent.id));
|
|
1764
1765
|
}
|
|
1765
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* Continue a Claude agent's conversation in the Claude desktop app (`claude --desktop --resume`):
|
|
1769
|
+
* its session here is closed first, and it waits until you resume it here, where it picks up
|
|
1770
|
+
* what was said in the desktop app (the same session).
|
|
1771
|
+
*/
|
|
1772
|
+
async openInDesktop(ref) {
|
|
1773
|
+
const agent = this.mustGet(ref);
|
|
1774
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
1775
|
+
if ((agent.engine ?? DEFAULT_ENGINE) !== 'claude') throw err(409, 'Only Claude Code sessions open in the Claude desktop app');
|
|
1776
|
+
if (agent.isolation === 'container') throw err(409, 'Its session runs in a container; the desktop app cannot reach it');
|
|
1777
|
+
if (agent.movedTo) throw err(409, `${agent.name} moved to ${agent.movedTo.machine}; open it in the desktop app there`);
|
|
1778
|
+
if (!agent.sessionId) throw err(409, 'It has no session yet: start it first');
|
|
1779
|
+
if (!agent.workdir || !fs.existsSync(agent.workdir)) throw err(409, `Working folder ${agent.workdir} no longer exists`);
|
|
1780
|
+
if (this.runtimes.get(agent.id)?.pty) await this.stop(agent.id);
|
|
1781
|
+
const args = [...(this.config.claudeArgs ?? []), '--desktop', '--resume', agent.sessionId];
|
|
1782
|
+
// In a terminal of its own: Claude Code refuses --desktop when its output isn't one.
|
|
1783
|
+
const out = await new Promise((resolve) => {
|
|
1784
|
+
let child;
|
|
1785
|
+
try {
|
|
1786
|
+
child = pty.spawn(this.config.claudeBin, args, { name: 'xterm-256color', cols: 120, rows: 30, cwd: agent.workdir, env: { ...process.env, FORCE_COLOR: '0' } });
|
|
1787
|
+
} catch (e) {
|
|
1788
|
+
return resolve({ code: -1, text: e.message });
|
|
1789
|
+
}
|
|
1790
|
+
let text = '';
|
|
1791
|
+
child.onData((d) => (text = (text + d).slice(-4000)));
|
|
1792
|
+
const timer = setTimeout(() => {
|
|
1793
|
+
child.kill();
|
|
1794
|
+
resolve({ code: null, text });
|
|
1795
|
+
}, 30_000);
|
|
1796
|
+
child.onExit(({ exitCode }) => {
|
|
1797
|
+
clearTimeout(timer);
|
|
1798
|
+
resolve({ code: exitCode, text });
|
|
1799
|
+
});
|
|
1800
|
+
});
|
|
1801
|
+
if (out.code !== 0) {
|
|
1802
|
+
const why = out.text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '').trim().split('\n').filter(Boolean).slice(-3).join(' ') || (out.code === null ? 'it did not finish in 30 seconds' : `exit code ${out.code}`);
|
|
1803
|
+
throw err(502, `Couldn't open it in the Claude desktop app: ${why}`);
|
|
1804
|
+
}
|
|
1805
|
+
this.patch(agent.id, { desktop: { at: now(), sessionId: agent.sessionId } });
|
|
1806
|
+
this.changed(this.store.get(agent.id));
|
|
1807
|
+
return this.view(this.store.get(agent.id));
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1766
1810
|
// ------------------------------------------------- sessions started elsewhere
|
|
1767
1811
|
|
|
1768
|
-
/** Claude Code sessions started outside AI Agent Manager (in a terminal), to continue here. */
|
|
1812
|
+
/** Claude Code sessions started outside AI Agent Manager (in a terminal or the Claude desktop app), to continue here. */
|
|
1769
1813
|
scanSessions({ days = 30 } = {}) {
|
|
1770
1814
|
const known = new Set(this.store.list().flatMap((a) => a.sessions.map((x) => x.id)));
|
|
1771
1815
|
return scanClaudeSessions({ days: Math.max(1, Math.min(365, Number(days) || 30)), known, excludeDir: paths.home });
|
|
@@ -1787,7 +1831,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1787
1831
|
continue;
|
|
1788
1832
|
}
|
|
1789
1833
|
if (s.inUse && !copy) {
|
|
1790
|
-
out.push({ sessionId: s.sessionId, error:
|
|
1834
|
+
out.push({ sessionId: s.sessionId, error: `Still open in ${s.source === 'desktop' ? 'the Claude desktop app' : 'a terminal'}: close it there first, or continue a copy` });
|
|
1791
1835
|
continue;
|
|
1792
1836
|
}
|
|
1793
1837
|
let sessionId = s.sessionId;
|
|
@@ -1803,7 +1847,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1803
1847
|
engine: 'claude',
|
|
1804
1848
|
start,
|
|
1805
1849
|
sessionId,
|
|
1806
|
-
imported: { from: 'terminal', sessionId: s.sessionId, copy: sessionId !== s.sessionId, at: now() },
|
|
1850
|
+
imported: { from: s.source === 'desktop' ? 'desktop' : 'terminal', sessionId: s.sessionId, copy: sessionId !== s.sessionId, at: now() },
|
|
1807
1851
|
});
|
|
1808
1852
|
out.push({ sessionId: s.sessionId, agentId: agent.id, name: agent.name });
|
|
1809
1853
|
}
|
|
@@ -1956,13 +2000,17 @@ export class AgentManager extends EventEmitter {
|
|
|
1956
2000
|
reloadBrief(id) {
|
|
1957
2001
|
const agent = this.store.get(id);
|
|
1958
2002
|
if (!agent) return;
|
|
1959
|
-
const
|
|
2003
|
+
const before = agent.brief; // patch() below changes agent.brief in place
|
|
2004
|
+
const brief = loadBrief(id, before);
|
|
1960
2005
|
if (brief) {
|
|
1961
2006
|
this.patch(id, { brief });
|
|
2007
|
+
const finished = brief.status === 'done' && before?.status !== 'done';
|
|
2008
|
+
// Asked to improve: its next update of the brief as done is scored again (also with auto off).
|
|
2009
|
+
const improved = brief.status === 'done' && agent.improving && brief.updatedAt !== agent.improving.briefAt;
|
|
1962
2010
|
// The agent says it is done: have the judge check the work before the person looks.
|
|
1963
|
-
if (
|
|
2011
|
+
if (finished && this.judge.enabled) this.requestReview(id, { trigger: 'brief' }).catch(() => {});
|
|
1964
2012
|
// ...and score it for the reviewer ("scorecard": { "auto": false } in config.json turns this off).
|
|
1965
|
-
if (
|
|
2013
|
+
if ((improved || (finished && this.config.scorecard?.auto !== false)) && !NO_REPO.has(agent.kind) && this.changesSource) this.summarize(id, 'scorecard').catch(() => {});
|
|
1966
2014
|
}
|
|
1967
2015
|
if (agent.planReview) {
|
|
1968
2016
|
const file = loadPlanFile(id, agent.planReview);
|
|
@@ -2230,24 +2278,42 @@ export class AgentManager extends EventEmitter {
|
|
|
2230
2278
|
const cur = this.store.get(agent.id);
|
|
2231
2279
|
if (!cur) return null;
|
|
2232
2280
|
return this.view(
|
|
2233
|
-
this.patch(
|
|
2234
|
-
|
|
2235
|
-
summaryPatch(cur, 'scorecard', {
|
|
2281
|
+
this.patch(agent.id, {
|
|
2282
|
+
...summaryPatch(cur, 'scorecard', {
|
|
2236
2283
|
data: sc,
|
|
2237
2284
|
cut,
|
|
2238
2285
|
diffHash: hash(data.diff),
|
|
2239
2286
|
markdown: scorecardMarkdown(sc, cur.name),
|
|
2240
2287
|
writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
|
|
2241
2288
|
postedTo: cur.docs?.scorecard?.postedTo ?? null,
|
|
2289
|
+
// After Improve: the score it had, to show how far it moved.
|
|
2290
|
+
improvedFrom: cur.improving ? cur.improving.from : null,
|
|
2242
2291
|
at: now(),
|
|
2243
2292
|
briefUpdatedAt: cur.brief?.updatedAt ?? null,
|
|
2244
2293
|
pending: false,
|
|
2245
2294
|
error: null,
|
|
2246
2295
|
}),
|
|
2247
|
-
|
|
2296
|
+
improving: null,
|
|
2297
|
+
}),
|
|
2248
2298
|
);
|
|
2249
2299
|
}
|
|
2250
2300
|
|
|
2301
|
+
/**
|
|
2302
|
+
* Improve before review: the agent gets its scorecard's low scores, unmet criteria and where a
|
|
2303
|
+
* reviewer would look, and its changes are scored again when it next updates its brief as done.
|
|
2304
|
+
* `send` delivers the message (the backlog reopens an item in review); else it is told directly.
|
|
2305
|
+
*/
|
|
2306
|
+
improve(ref, { send } = {}) {
|
|
2307
|
+
const agent = this.mustGet(ref);
|
|
2308
|
+
const doc = agent.docs?.scorecard;
|
|
2309
|
+
if (doc?.pending) throw Object.assign(new Error('The changes are being scored; try again when that is done'), { status: 409 });
|
|
2310
|
+
if (!doc?.data) throw Object.assign(new Error('Score the changes first'), { status: 409 });
|
|
2311
|
+
const text = improveMessage(doc.data);
|
|
2312
|
+
if (send) send(text);
|
|
2313
|
+
else this.tell(agent.id, text, { source: 'scorecard' });
|
|
2314
|
+
return this.view(this.patch(agent.id, { improving: { from: doc.data.overall, at: now(), briefAt: agent.brief?.updatedAt ?? null } }));
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2251
2317
|
/** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
|
|
2252
2318
|
async runDiagrams(agent, ctx) {
|
|
2253
2319
|
const cur0 = this.store.get(agent.id) ?? agent;
|
|
@@ -2452,7 +2518,8 @@ export class AgentManager extends EventEmitter {
|
|
|
2452
2518
|
this.plugins.emit('agent.message', { agent: this.auditView(agent), text, source });
|
|
2453
2519
|
if (!this.outbox.has(agent.id)) this.outbox.set(agent.id, []);
|
|
2454
2520
|
this.outbox.get(agent.id).push({ text, source, at: now() });
|
|
2455
|
-
|
|
2521
|
+
// In the Claude desktop app: it waits for Resume here (two places writing one conversation mix it up).
|
|
2522
|
+
if (!this.runtimes.get(agent.id)?.pty && agent.workdir && agent.status !== 'preparing' && !agent.desktop) this.start(agent.id);
|
|
2456
2523
|
this.changed(agent);
|
|
2457
2524
|
return this.view(this.store.get(agent.id));
|
|
2458
2525
|
}
|
package/src/scorecard.js
CHANGED
|
@@ -156,6 +156,29 @@ export function combine(reply, f, hunks) {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
|
|
159
|
+
const IMPROVE_BELOW = 80; // scores under this are named in the Improve message
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Improve: the agent's own scorecard sent back to it before a person reviews the work. The scores
|
|
163
|
+
* to raise (lowest first, with why), the acceptance criteria not met or not verified, and where a
|
|
164
|
+
* reviewer would look.
|
|
165
|
+
*/
|
|
166
|
+
export function improveMessage(sc) {
|
|
167
|
+
const weak = DIMENSIONS.filter(([k]) => sc.scores[k] && sc.scores[k].score < IMPROVE_BELOW)
|
|
168
|
+
.sort(([a], [b]) => sc.scores[a].score - sc.scores[b].score)
|
|
169
|
+
.map(([k, label]) => `- ${label} ${sc.scores[k].score}/100: ${[sc.scores[k].why, ...sc.scores[k].notes].filter(Boolean).join('; ')}`);
|
|
170
|
+
const criteria = sc.criteria.filter((c) => c.status !== 'met').map((c) => `- ${c.status === 'not_met' ? 'Not met' : 'Not verified'}: ${c.criterion}${c.evidence ? ` (${c.evidence})` : ''}`);
|
|
171
|
+
const look = sc.look.map((l) => `- ${l.file}: ${l.reason}`);
|
|
172
|
+
return [
|
|
173
|
+
`Before this goes to review: the review scorecard rates your changes ${sc.overall ?? '?'}/100 (${RECOMMENDATION[sc.recommendation]}${sc.reasons.length ? `: ${sc.reasons.join('; ')}` : ''}). Improve them where they fall short.`,
|
|
174
|
+
weak.length ? `Scores to raise:\n${weak.join('\n')}` : '',
|
|
175
|
+
criteria.length ? `Acceptance criteria:\n${criteria.join('\n')}` : '',
|
|
176
|
+
look.length ? `Where a reviewer would look first:\n${look.join('\n')}` : '',
|
|
177
|
+
'Fix what these point at: add or fix tests and run them, meet the criteria, handle the risky spots. Stay within the task, and say in the brief what you left as it is and why. Update the brief when you are done; the changes are scored again then.',
|
|
178
|
+
]
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.join('\n\n');
|
|
181
|
+
}
|
|
159
182
|
|
|
160
183
|
/** Markdown: for Copy / Download, the pull request's description and a PR comment. */
|
|
161
184
|
export function scorecardMarkdown(sc, title = '') {
|
package/src/server.js
CHANGED
|
@@ -268,6 +268,15 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
268
268
|
);
|
|
269
269
|
api.get('/agents/:id/specs/:name', wrap((req) => manager.specDoc(req.params.id, req.params.name)));
|
|
270
270
|
api.post('/agents/:id/scorecard/post', wrap((req) => review.postScorecard(req.params.id)));
|
|
271
|
+
// Improve: its scorecard goes back to the agent; a backlog item in review is reopened with it.
|
|
272
|
+
api.post(
|
|
273
|
+
'/agents/:id/scorecard/improve',
|
|
274
|
+
wrap((req) => {
|
|
275
|
+
const id = manager.mustGet(req.params.id).id;
|
|
276
|
+
const it = backlog.items().find((i) => i.agentId === id && i.status === 'review');
|
|
277
|
+
return manager.improve(id, it ? { send: (note) => backlog.reopen(it.id, { note }) } : {});
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
271
280
|
api.post('/agents/:id/plan', wrap((req) => manager.startPlanning(req.params.id)));
|
|
272
281
|
api.post('/agents/:id/plan/approve', wrap((req) => manager.approvePlan(req.params.id, req.body?.note)));
|
|
273
282
|
api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
|
|
@@ -407,6 +416,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
407
416
|
);
|
|
408
417
|
api.post('/agents/:id/start', wrap((req) => manager.start(req.params.id, req.body ?? {})));
|
|
409
418
|
api.post('/agents/:id/stop', wrap((req) => manager.stop(req.params.id)));
|
|
419
|
+
api.post('/agents/:id/desktop', wrap((req) => manager.openInDesktop(req.params.id)));
|
|
410
420
|
api.post('/agents/:id/refresh', wrap((req) => manager.refresh(req.params.id, { withPrs: true })));
|
|
411
421
|
api.post('/agents/:id/links', wrap((req) => manager.addLink(req.params.id, req.body ?? {})));
|
|
412
422
|
api.delete('/agents/:id/links', wrap((req) => manager.removeLink(req.params.id, req.query)));
|
package/src/sessions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Claude Code sessions started outside AI Agent Manager (a plain `claude` in a terminal
|
|
2
|
-
// can be continued here. Claude Code keeps each session as ~/.claude/projects/<cwd>/<id>.jsonl;
|
|
1
|
+
// Claude Code sessions started outside AI Agent Manager (a plain `claude` in a terminal, or the
|
|
2
|
+
// Claude desktop app's Code tab), so they can be continued here. Claude Code keeps each session as ~/.claude/projects/<cwd>/<id>.jsonl;
|
|
3
3
|
// the start and end of each file say where it ran, its title, branch and first ask.
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import fs from 'node:fs';
|
|
@@ -15,6 +15,46 @@ const encode = (p) => {
|
|
|
15
15
|
return real.replace(/[^a-zA-Z0-9]/g, '-');
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
/** Where the Claude desktop app keeps its data (its list of Code sessions is under it). */
|
|
19
|
+
const desktopDir = () =>
|
|
20
|
+
process.env.AGENTMGR_CLAUDE_DESKTOP_DIR ||
|
|
21
|
+
(process.platform === 'darwin'
|
|
22
|
+
? path.join(os.homedir(), 'Library', 'Application Support', 'Claude')
|
|
23
|
+
: process.platform === 'win32'
|
|
24
|
+
? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude')
|
|
25
|
+
: path.join(os.homedir(), '.config', 'Claude'));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The Claude desktop app's Code sessions on this computer, by Claude Code session ID: its title
|
|
29
|
+
* and whether it's archived. Read from the app's own records (claude-code-sessions/<account>/<org>/
|
|
30
|
+
* local_*.json, each naming its `cliSessionId`); an app that keeps them elsewhere gives none, and
|
|
31
|
+
* its sessions are still found by their transcripts.
|
|
32
|
+
*/
|
|
33
|
+
export function desktopSessions() {
|
|
34
|
+
const out = new Map();
|
|
35
|
+
const root = path.join(desktopDir(), 'claude-code-sessions');
|
|
36
|
+
const walk = (dir, depth) => {
|
|
37
|
+
let entries = [];
|
|
38
|
+
try {
|
|
39
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
const p = path.join(dir, e.name);
|
|
45
|
+
if (e.isDirectory() && depth < 3) walk(p, depth + 1);
|
|
46
|
+
else if (e.isFile() && /^local_.*\.json$/.test(e.name)) {
|
|
47
|
+
try {
|
|
48
|
+
const r = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
49
|
+
if (typeof r.cliSessionId === 'string') out.set(r.cliSessionId, { title: typeof r.title === 'string' ? r.title : null, archived: Boolean(r.isArchived) });
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(root, 0);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
18
58
|
/** The first and last `bytes` of a file as JSON records (partial lines dropped). */
|
|
19
59
|
function edges(file, size, bytes = 96 * 1024) {
|
|
20
60
|
const fd = fs.openSync(file, 'r');
|
|
@@ -84,7 +124,7 @@ export function runningClaude() {
|
|
|
84
124
|
}
|
|
85
125
|
|
|
86
126
|
/**
|
|
87
|
-
* Interactive Claude Code sessions from the last `days
|
|
127
|
+
* Interactive Claude Code sessions from the last `days` (terminal and Claude desktop app), newest first, leaving out `known`
|
|
88
128
|
* session IDs (AI Agent Manager's own) and anything run under `excludeDir` (its one-off calls and
|
|
89
129
|
* workspaces). inUse: "open" when a running claude names the session, "maybe" when one runs in
|
|
90
130
|
* its folder and the session was active in the last 10 minutes.
|
|
@@ -121,6 +161,7 @@ export function scanClaudeSessions({ days = 30, known = new Set(), excludeDir, l
|
|
|
121
161
|
}
|
|
122
162
|
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
123
163
|
const running = runningClaude();
|
|
164
|
+
const desktop = desktopSessions();
|
|
124
165
|
const out = [];
|
|
125
166
|
for (const f of files.slice(0, limit)) {
|
|
126
167
|
let recs;
|
|
@@ -132,10 +173,15 @@ export function scanClaudeSessions({ days = 30, known = new Set(), excludeDir, l
|
|
|
132
173
|
const all = [...recs.head, ...recs.tail];
|
|
133
174
|
const withCwd = all.find((r) => r.cwd);
|
|
134
175
|
if (!withCwd) continue;
|
|
135
|
-
// Headless runs (claude -p, the SDK) aren't conversations to continue.
|
|
136
|
-
if (all.some((r) => r.entrypoint && r.entrypoint !== 'cli')) continue;
|
|
137
176
|
const firstAsk = recs.head.map(typed).find(Boolean);
|
|
138
177
|
if (!firstAsk) continue;
|
|
178
|
+
// Where it was started: a terminal (`cli`), the Claude desktop app (in its list of sessions, or
|
|
179
|
+
// saying so), an editor, or a program (claude -p, the SDK, built agents), whose runs aren't
|
|
180
|
+
// conversations to continue.
|
|
181
|
+
const app = desktop.get(f.id);
|
|
182
|
+
const entry = all.find((r) => r.entrypoint)?.entrypoint ?? 'cli';
|
|
183
|
+
const source = app || /desktop/i.test(entry) ? 'desktop' : entry === 'cli' ? 'terminal' : /vscode|jetbrains|ide/i.test(entry) ? 'editor' : null;
|
|
184
|
+
if (!source) continue;
|
|
139
185
|
const cwd = withCwd.cwd;
|
|
140
186
|
if (excludeDir && (cwd === excludeDir || cwd.startsWith(`${excludeDir}/`))) continue;
|
|
141
187
|
const last = (type, key) => [...all].reverse().find((r) => r.type === type && r[key])?.[key];
|
|
@@ -147,7 +193,9 @@ export function scanClaudeSessions({ days = 30, known = new Set(), excludeDir, l
|
|
|
147
193
|
sessionId: f.id,
|
|
148
194
|
cwd,
|
|
149
195
|
exists: fs.existsSync(cwd),
|
|
150
|
-
|
|
196
|
+
source,
|
|
197
|
+
archived: app?.archived || undefined,
|
|
198
|
+
title: app?.title || last('custom-title', 'customTitle') || last('agent-name', 'agentName') || last('ai-title', 'aiTitle') || last('summary', 'summary') || null,
|
|
151
199
|
firstAsk: firstAsk.slice(0, 300),
|
|
152
200
|
lastAsk: lastAsk && lastAsk !== firstAsk ? lastAsk.slice(0, 300) : null,
|
|
153
201
|
branch: branch === 'HEAD' ? null : branch,
|