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 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 any time with `claude --resume`.
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 with `claude` in a terminal on this machine, from the last 7, 30 or 90 days: grouped by folder, with title, first and last ask, branch, size and last activity. Headless runs (`claude -p`, the SDK) 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
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).toLocaleString()} (input ${u.input.toLocaleString()} + output ${u.output.toLocaleString()}) · cache write ${u.cacheCreation.toLocaleString()} · cache read ${u.cacheRead.toLocaleString()} · total incl. cache ${u.total.toLocaleString()} · ${u.requests} API requests`;
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
- if (body.dataset.rendered !== d.at) {
405
- body.innerHTML = docView.kind === 'scorecard' ? scorecardHtml(d, esc) : d.data ? humanAiHtml(d) : docHtml(d.markdown);
406
- body.dataset.rendered = d.at;
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').innerHTML = projectGroups(shown)
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').innerHTML = `
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></div>`;
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
- view.innerHTML = items.length
1252
- ? `<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>`
1253
- : `<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>`;
1254
- view.insertAdjacentHTML('beforeend', judgeLine());
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
- body.innerHTML = `
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
- renderList();
1968
- renderBacklog();
1969
- landUi.render();
1970
- renderInbox();
1971
- renderRace();
1972
- renderTeam();
1973
- renderDocDialog();
1974
- renderDetail();
1975
- term.syncOverlay();
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
- history.replaceState(null, '', id ? `#${id}` : '#');
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
- this.xterm.write(msg.d);
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.inUse ? `<span class="tag warn" title="${s.inUse === 'open' ? 'A claude process in a terminal has this session open' : 'A claude process runs in this folder and the session was active minutes ago'}">${s.inUse === 'open' ? 'open in a terminal' : 'maybe open in a terminal'}</span>` : ''}
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 terminal keeps the original' : '',
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
- agents.set(msg.agent.id, msg.agent);
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
- render();
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>
@@ -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 {
@@ -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
- /** A small badge: "82 · Ready to approve". */
23
- export function badgeHtml(doc, esc) {
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></span>
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
- this.manager.write(agentId, message);
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: 'Still open in a terminal: close it there first, or continue a copy' });
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 brief = loadBrief(id, agent.brief);
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 (brief.status === 'done' && agent.brief?.status !== 'done' && this.judge.enabled) this.requestReview(id, { trigger: 'brief' }).catch(() => {});
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 (brief.status === 'done' && agent.brief?.status !== 'done' && this.config.scorecard?.auto !== false && !NO_REPO.has(agent.kind) && this.changesSource) this.summarize(id, 'scorecard').catch(() => {});
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
- agent.id,
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
- if (!this.runtimes.get(agent.id)?.pty && agent.workdir && agent.status !== 'preparing') this.start(agent.id);
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), so they
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`, newest first, leaving out `known`
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
- title: last('custom-title', 'customTitle') || last('agent-name', 'agentName') || last('ai-title', 'aiTitle') || last('summary', 'summary') || null,
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,