ai-agentmanager 1.14.0 → 1.16.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.0 (2026-11-03)
4
+
5
+ - **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.
6
+ - Switching agents with a terminal open no longer leaves the previous agent in the address (a reload opened the wrong one).
7
+ - **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*).
8
+ - **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 }`).
9
+ - 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.
10
+
11
+ ## 1.15.0 (2026-11-03)
12
+
13
+ - **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.
14
+ - **Requests you keep making become playbooks:** when you've typed alike requests 3 times (new agents and backlog items, with numbers, names, URLs and issue keys set aside), the Inbox offers **Make it a playbook?**. **Save as playbook…** opens the editor prefilled: the shared words become the request, the part that changes becomes `{{what}}`, and the projects they ran in become its projects. **Not now** stops offering it. Requests already saved as a playbook aren't offered. `"repeats": { "suggestAfter": 3, "enabled": true }` in config.json.
15
+ - **Commit all** no longer fails with `index.lock: File exists` when it lands on the app's own background `git status` of the same worktree. It retries for a moment.
16
+
3
17
  ## 1.14.0 (2026-11-02)
4
18
 
5
19
  - **Fewer questions: approval rules.** When you allow the same command 3 times in a repo (`npm test`, an MCP tool, fetching from a site), the inbox offers **Always allow**. Allowed rules go to every Claude Code agent in that repo at launch and answer its open prompts, including the ones waiting when you allow it. Never offered for compound commands or ones that delete, escalate, publish or reach the network. Settings → Approval rules and `agentmgr rules` list them, with how many prompts each answered, and remove them.
package/README.md CHANGED
@@ -388,6 +388,7 @@ Each card has at most **three points of about 12 words each**, so they're easy t
388
388
  - **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
389
  - **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
390
  - **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 }`.
391
+ - **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
392
  - **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
393
  - **Commit all** commits everything in the worktree, new files included, with your git name and email.
393
394
  - **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.
@@ -514,6 +515,21 @@ Queue asks instead of starting them. Use **+ Add** in the Backlog tab, **Queue i
514
515
 
515
516
  The backlog is saved with the rest of the state.
516
517
 
518
+ ### Autopilot
519
+
520
+ Autopilot takes a project's finished items the rest of the way when the [review scorecard](#review-changes) says the work is ready. Set it per project with **🛫** in the project's row in the Backlog tab, or `agentmgr backlog autopilot <project> off|pr|merge`. It is off by default.
521
+
522
+ - **Off:** items wait in review for **Accept** or **Reopen**, as above.
523
+ - **Open PRs:** when an item reaches review, its changes are scored (again, if they changed since the last score). If the scorecard says *Ready to approve*, autopilot commits what's left (the item's title as the message), pushes and opens the pull request, with the scorecard in its description.
524
+ - **Merge:** also merges that pull request once its checks pass and GitHub allows it. A branch that fell behind is brought up to date first, and failing checks go back to the agent through [Landing](#landing-pull-requests).
525
+
526
+ What it does with work that isn't ready:
527
+
528
+ - **Not ready, for a concrete reason** (failing CI, acceptance criteria not met, barely verified): the item goes back to the same agent with those reasons. This happens twice at most; after that it waits for you.
529
+ - **Look at these first, a failed step, or a closed pull request:** autopilot stops and it waits for you in the **Inbox** ("Autopilot held"), saying why. You can **Review changes**, **Ship it** (go ahead anyway), or **Send back** with a note.
530
+
531
+ An item whose pull request merges is done. Each item shows where autopilot stands ("pull request open #12", "waiting for checks to merge", "sent back to the agent"). **Ship it** is also on every item in review, so you can ship one by hand with autopilot off. Plugins can still refuse the commit, the pull request or the merge (`agent.commit`, `agent.pr`, `agent.merge`).
532
+
517
533
  ## Playbooks
518
534
 
519
535
  Saved requests you run again: **Backlog → Playbooks**, **Save as playbook…** in New agents, or `agentmgr playbook`. A playbook is a request, the projects to run it in (one agent each), and optionally a model, permission mode, priority and schedule:
@@ -524,6 +540,13 @@ Saved requests you run again: **Backlog → Playbooks**, **Save as playbook…**
524
540
 
525
541
  Agents started by a playbook are marked **▶ <playbook>**, and the playbook lists the agents of its last run.
526
542
 
543
+ **Requests you keep making become playbooks.** AI Agent Manager remembers the requests you type for new agents and backlog items (the last 500, in `~/.agentmanager/asks.json`). Requests started by playbooks, triggers or the assistant aren't counted. When 3 read alike, the Inbox offers **Make it a playbook?** with the examples. "Alike" means the same words once numbers, quoted names, URLs, issue keys and filler words are set aside.
544
+
545
+ - **Save as playbook…** opens the playbook editor prefilled. The words the requests share become the request, and the part that changes becomes `{{what}}`: "Upgrade react to the latest version" and "Upgrade vite to the latest version" become "Upgrade `{{what}}` to the latest version". The projects they ran in become the playbook's projects.
546
+ - **Not now** stops offering that one.
547
+ - A request that is already a playbook isn't offered.
548
+ - Change the count with `"repeats": { "suggestAfter": 3 }`, or turn it off with `"repeats": { "enabled": false }`.
549
+
527
550
  ```sh
528
551
  agentmgr playbook add "Flaky tests" "Find yesterday's flaky tests in CI and fix or quarantine them" --projects web,api --every weekday --at 09:00
529
552
  agentmgr playbook ls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.14.0",
3
+ "version": "1.16.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();
@@ -127,6 +128,7 @@ $('#new-playbook').onclick = () => {
127
128
  };
128
129
  $('#triggers-open').onclick = () => triggersUi.open();
129
130
  let builtReports = []; // agents built with agentkit that report here (runs, approvals)
131
+ let repeatsState = { suggestions: [] }; // requests you keep making, offered as playbooks (src/repeats.js)
130
132
  let rulesState = { allowed: [], suggestions: [] }; // approval rules learned from your answers (src/rules.js)
131
133
  const builtUi = initBuilt({
132
134
  api, esc, toast, $,
@@ -157,8 +159,11 @@ function fmtTokens(n) {
157
159
 
158
160
  const ioOf = (u) => (u ? (u.io ?? u.input + u.output) : 0);
159
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
+
160
165
  function usageTitle(u) {
161
- 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`;
162
167
  }
163
168
 
164
169
  const tokensOpen = new Set(); // agents whose token breakdown is expanded
@@ -400,9 +405,11 @@ function renderDocDialog() {
400
405
  else if (specKind && !d?.loaded) body.innerHTML = '<p class="hint">Loading…</p>';
401
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>`;
402
407
  else if (d?.markdown) {
403
- if (body.dataset.rendered !== d.at) {
404
- body.innerHTML = docView.kind === 'scorecard' ? scorecardHtml(d, esc) : d.data ? humanAiHtml(d) : docHtml(d.markdown);
405
- 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;
406
413
  renderDiagrams(body);
407
414
  }
408
415
  }
@@ -590,6 +597,12 @@ const OUTCOME = {
590
597
  'in progress': ['🔄', 'In progress'],
591
598
  };
592
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
+
593
606
  /** End result, status and blockers: first thing a reviewer reads. */
594
607
  function resultsCard(a) {
595
608
  const r = a.results;
@@ -598,6 +611,7 @@ function resultsCard(a) {
598
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>`;
599
612
  return `<div class="results ${r.outcome.replace(' ', '-')}">
600
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)}
601
615
  ${r.blockers.length ? `<ul class="blockers">${r.blockers.map((x) => item(x, 'blocker')).join('')}</ul>` : ''}
602
616
  ${r.delivered.length ? `<ul class="delivered">${r.delivered.map((x) => item(x, 'delivered')).join('')}</ul>` : '<p class="hint">Nothing delivered yet.</p>'}
603
617
  ${r.notes ? `<div class="md">${md(r.notes)}</div>` : ''}
@@ -730,6 +744,7 @@ function agentItem(a) {
730
744
  ${a.prs.length ? `<span>${a.prs.length} PR</span>` : ''}
731
745
  ${a.bugs.length ? `<span>${a.bugs.length} bug</span>` : ''}
732
746
  ${a.usage?.total ? `<span title="${esc(usageTitle(a.usage))}">${fmtTokens(ioOf(a.usage))} tok</span>` : ''}
747
+ ${scoreChip(a, esc)}
733
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('')}
734
749
  ${a.brief?.summary?.total
735
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>`
@@ -826,7 +841,7 @@ function renderList() {
826
841
  const totals = new Map();
827
842
  for (const a of all) totals.set(a.projectInfo?.key ?? 'other', (totals.get(a.projectInfo?.key ?? 'other') ?? 0) + 1);
828
843
 
829
- $('#agent-list').innerHTML = projectGroups(shown)
844
+ morph($('#agent-list'), projectGroups(shown)
830
845
  .map((g) => {
831
846
  const total = totals.get(g.key) ?? g.agents.length;
832
847
  const n = g.agents.length;
@@ -847,7 +862,7 @@ function renderList() {
847
862
  ${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
848
863
  </li>`;
849
864
  })
850
- .join('');
865
+ .join(''));
851
866
  }
852
867
 
853
868
  // ------------------------------------------------------------------ backlog
@@ -855,7 +870,7 @@ function renderList() {
855
870
  const ITEM_ACTIONS = {
856
871
  queued: [['up', '▲', 'Move up'], ['down', '▼', 'Move down'], ['start', 'Start now', 'Start now, even if the project is at its limit'], ['cancel', 'Cancel', '']],
857
872
  active: [['open', 'Open', 'Open its agent'], ['accept', 'Done', 'Mark done now'], ['cancel', 'Cancel', '']],
858
- review: [['accept', 'Accept', 'Accept the work: done'], ['reopen', 'Reopen', 'Send it back to the same agent with a note'], ['open', 'Open', 'Open its agent']],
873
+ review: [['accept', 'Accept', 'Accept the work: done'], ['ship', 'Ship it', 'Commit, push and open its pull request (and merge it, at the Merge autopilot level)'], ['reopen', 'Reopen', 'Send it back to the same agent with a note'], ['open', 'Open', 'Open its agent']],
859
874
  blocked: [['start', 'Retry', 'Retry the agent'], ['open', 'Open', 'Open its agent'], ['cancel', 'Cancel', '']],
860
875
  done: [['reopen', 'Reopen', ''], ['open', 'Open', ''], ['remove', '✕', 'Remove from the list']],
861
876
  cancelled: [['remove', '✕', 'Remove from the list']],
@@ -876,7 +891,7 @@ function renderBacklog() {
876
891
  }
877
892
  const proj = Object.fromEntries(backlogState.projects.map((p) => [p.key, p]));
878
893
  const count = (s) => backlogState.items.filter((i) => i.status === s).length;
879
- $('#backlog-view').innerHTML = `
894
+ morph($('#backlog-view'), `
880
895
  <div class="counts">
881
896
  <span>${count('queued')} queued</span><span>${count('active')} active</span>
882
897
  ${count('review') ? `<span class="fail">${count('review')} to review</span>` : '<span>0 to review</span>'}
@@ -895,13 +910,25 @@ function renderBacklog() {
895
910
  ${p.shortcut ? `<span class="shortcut">${esc(p.shortcut)}</span>` : ''}
896
911
  <span class="pname">${esc(g.name)}</span>
897
912
  <button class="link" data-bl="limit" data-project="${esc(g.key)}" data-name="${esc(g.name)}" title="How many items of this project agents work on at once">${p.limit} at a time ✎</button>
913
+ <label class="bl-autopilot" title="${esc(AUTOPILOT_HINT)}">🛫 <select aria-label="Autopilot" data-bl-autopilot="${esc(g.key)}">${Object.entries(AUTOPILOT_LEVELS).map(([v, l]) => `<option value="${v}" ${(p.autopilot ?? 'off') === v ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
898
914
  </div>
899
915
  <ul class="project-agents">${g.items.map(itemRow).join('')}</ul>
900
916
  </li>`;
901
917
  })
902
918
  .join('')}</ul>`
903
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>`}
904
- <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>`);
921
+ }
922
+
923
+ const AUTOPILOT_LEVELS = { off: 'Off', pr: 'Open PRs', merge: 'Merge' };
924
+ const AUTOPILOT_HINT = 'When an item is done and its review scorecard says ready: Open PRs commits, pushes and opens the pull request; Merge also merges it once checks pass. Not-ready work goes back to the agent; anything else waits for you in the inbox.';
925
+ const AUTOPILOT_STATE = { queued: 'going ahead', scoring: 'scoring the changes', shipping: 'opening the pull request', pr: 'pull request open', landing: 'waiting for checks to merge', merging: 'merging', returned: 'sent back to the agent', held: 'waiting for you', merged: 'merged' };
926
+ /** Where autopilot stands on an item, under it in the backlog. */
927
+ function autopilotLine(it) {
928
+ const ap = it.autopilot;
929
+ if (!ap?.state || !['review', 'active', 'done'].includes(it.status) || (it.status === 'active' && ap.state !== 'returned')) return '';
930
+ const pr = ap.pr ? ` <a href="${esc(ap.pr)}" target="_blank" rel="noopener">#${esc(ap.pr.split('/').pop())} ↗</a>` : '';
931
+ return `<p class="hint bl-ai bl-ap ${ap.state === 'held' ? 'held' : ''}">🛫 Autopilot: ${AUTOPILOT_STATE[ap.state] ?? esc(ap.state)}${pr}${ap.note ? ` · ${esc(ap.note)}` : ''}</p>`;
905
932
  }
906
933
 
907
934
  function itemRow(it) {
@@ -917,12 +944,14 @@ function itemRow(it) {
917
944
  <span class="mono">${it.id}</span>
918
945
  ${a ? `<span>agent <a href="#${a.id}" data-bl="open">${esc(a.name)}</a>${a.briefStatus ? ` · brief: ${esc(a.briefStatus)}` : ''}</span>` : ''}
919
946
  ${it.base ? `<span>⎇ from ${esc(it.base)}</span>` : ''}
947
+ ${['review', 'active'].includes(it.status) ? scoreChip(agents.get(it.agentId), esc) : ''}
920
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>`}
921
949
  </div>
950
+ ${autopilotLine(it)}
922
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>` : ''}
923
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>
924
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>` : ''}
925
- <div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).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>
926
955
  </li>`;
927
956
  }
928
957
 
@@ -942,6 +971,12 @@ function showTab(tab) {
942
971
  }
943
972
  for (const b of document.querySelectorAll('.side-tab')) b.onclick = () => showTab(b.dataset.tab);
944
973
 
974
+ $('#backlog-view').addEventListener('change', (e) => {
975
+ const key = e.target.dataset?.blAutopilot;
976
+ if (!key) return;
977
+ const level = e.target.value;
978
+ act(() => api('POST', '/backlog/settings', { project: key, autopilot: level }), level === 'off' ? 'Autopilot off' : `Autopilot: ${AUTOPILOT_LEVELS[level]}`);
979
+ });
945
980
  $('#backlog-view').addEventListener('click', async (e) => {
946
981
  const el = e.target.closest('[data-bl]');
947
982
  if (!el) return;
@@ -989,6 +1024,8 @@ $('#backlog-view').addEventListener('click', async (e) => {
989
1024
  return blApi('POST', `/backlog/${id}/split`, { parts: it.aiSplit.parts });
990
1025
  case 'keep-whole':
991
1026
  return blApi('POST', `/backlog/${id}/keep-whole`);
1027
+ case 'ship':
1028
+ return act(() => api('POST', `/backlog/${id}/ship`), 'Shipping: opening its pull request');
992
1029
  case 'open': {
993
1030
  const agentId = it?.agent?.id;
994
1031
  if (!agentId) return;
@@ -1014,7 +1051,7 @@ function flags(a) {
1014
1051
  }
1015
1052
 
1016
1053
  const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
1017
- const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, rule: 2, review: 2, stuck: 3, conflict: 4 };
1054
+ const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
1018
1055
  /** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
1019
1056
  function needsFor(a, { both = false } = {}) {
1020
1057
  const items = [];
@@ -1044,12 +1081,22 @@ const repoLabel = (k) => String(k).replace(/^dir:/, '').split('/').filter(Boolea
1044
1081
  function ruleNeeds() {
1045
1082
  return rulesState.suggestions.map((r) => ({ a: { id: 'rules', name: repoLabel(r.repo), priority: 2 }, kind: 'rule', key: `rule:${r.repo}:${r.rule}`, r }));
1046
1083
  }
1084
+ /** Backlog work autopilot held for you. */
1085
+ function autopilotNeeds() {
1086
+ return backlogState.items
1087
+ .filter((it) => it.status === 'review' && it.autopilot?.state === 'held' && it.autopilot.round === it.reviewAt)
1088
+ .map((it) => ({ a: { id: it.agentId ?? '', name: it.agent?.name ?? it.projectName, priority: it.priority }, kind: 'autopilot', key: `autopilot:${it.id}`, it }));
1089
+ }
1090
+ /** Requests you keep making, offered as a playbook. */
1091
+ function repeatNeeds() {
1092
+ return repeatsState.suggestions.map((r) => ({ a: { id: 'repeats', name: r.projects.join(', ') || 'Requests', priority: 3 }, kind: 'repeat', key: `repeat:${r.key}`, r }));
1093
+ }
1047
1094
  function inboxItems() {
1048
- return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds()]
1095
+ return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds(), ...autopilotNeeds(), ...repeatNeeds()]
1049
1096
  .sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
1050
1097
  }
1051
1098
 
1052
- const NEED_LABEL = { rule: 'Always allow?', approval: 'Approval', team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
1099
+ const NEED_LABEL = { repeat: 'Make it a playbook?', autopilot: 'Autopilot held', rule: 'Always allow?', approval: 'Approval', team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
1053
1100
  /** Why the AI judge thinks so, as a small tag. */
1054
1101
  const aiTag = (confidence, what = 'AI') => `<span class="tag ai" title="Judged by a model from the agent's activity; your reactions tune later calls">🤖 ${what}${confidence != null ? ` ${Math.round(confidence * 100)}%` : ''}</span>`;
1055
1102
  /** What a permission prompt asks about: the command, file or tool. */
@@ -1081,6 +1128,8 @@ function needCard(it, { inDetail = false } = {}) {
1081
1128
  ? `<a href="#" class="need-agent" data-ib="built-open">⚙ ${esc(a.name)}</a> <span class="hint">${esc(it.r.host)}</span>`
1082
1129
  : it.kind === 'rule'
1083
1130
  ? `<span class="need-agent" title="${esc(it.r.repo)}">${esc(a.name)}</span>`
1131
+ : it.kind === 'repeat'
1132
+ ? `<span class="need-agent">${esc(a.name)}</span>`
1084
1133
  : `<a href="#${a.id}" class="need-agent" data-goto="${a.id}">${prioChip(a.priority ?? 2)}${esc(a.name)}</a>${a.projectInfo?.name ? ` <span class="hint">${esc(a.projectInfo.name)}</span>` : ''}`;
1085
1134
  const head = `<div class="need-head"><span class="need-kind ${it.kind}">${NEED_LABEL[it.kind]}</span>${who}</div>`;
1086
1135
  const draft = (k, fallback = '') => esc(needDrafts.get(`${it.key}:${k}`) ?? fallback);
@@ -1090,6 +1139,16 @@ function needCard(it, { inDetail = false } = {}) {
1090
1139
  body = `<p class="need-title">Wants to run <code>${esc(p.tool)}</code> <span class="hint">asked ${ago(p.at)}</span></p>
1091
1140
  <pre class="need-input">${esc(JSON.stringify(p.input, null, 2).slice(0, 1500))}</pre>
1092
1141
  <div class="need-actions"><button class="small primary" data-ib="approve" data-allow="1">Approve</button><button class="small" data-ib="approve">Deny</button>${it.r.chat ? `<a class="button small ghost" href="${esc(it.r.chat)}" target="_blank" rel="noopener">Its chat ↗</a>` : ''}</div>`;
1142
+ } else if (it.kind === 'repeat') {
1143
+ const r = it.r;
1144
+ body = `<p class="need-title">You've asked for this ${r.count} times: <code>${esc(r.intent)}</code></p>
1145
+ <ul class="need-files">${r.examples.map((x) => `<li>${esc(x.length > 140 ? `${x.slice(0, 140)}…` : x)}</li>`).join('')}</ul>
1146
+ <p class="hint">Save it as a playbook to run it again in one click, or on a schedule.</p>
1147
+ <div class="need-actions"><button class="small primary" data-ib="repeat-save">Save as playbook…</button><button class="small" data-ib="repeat-dismiss">Not now</button></div>`;
1148
+ } else if (it.kind === 'autopilot') {
1149
+ body = `<p class="need-title">${esc(it.it.title)}</p>
1150
+ <p class="hint">${esc(it.it.autopilot.note ?? '')}</p>
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>`;
1093
1152
  } else if (it.kind === 'rule') {
1094
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>
1095
1154
  <p class="hint">Agents in ${esc(a.name)} won't ask about it again. Remove it any time in Settings → Approval rules.</p>
@@ -1121,6 +1180,7 @@ function needCard(it, { inDetail = false } = {}) {
1121
1180
  <div class="need-actions">
1122
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>' : ''}
1123
1182
  <button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
1183
+ ${improveHtml(a).replace('small primary', 'small')}
1124
1184
  </div>`;
1125
1185
  } else if (it.kind === 'team') {
1126
1186
  const t = it.t;
@@ -1204,10 +1264,12 @@ function renderInbox() {
1204
1264
  if (sideTab !== 'inbox') return;
1205
1265
  const view = $('#inbox-view');
1206
1266
  if (view.contains(document.activeElement) && document.activeElement.dataset.draft) return; // don't yank the field being typed in
1207
- view.innerHTML = items.length
1208
- ? `<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>`
1209
- : `<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>`;
1210
- 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
+ );
1211
1273
  }
1212
1274
 
1213
1275
  async function onNeed(e) {
@@ -1246,6 +1308,26 @@ async function onNeed(e) {
1246
1308
  const ids = card.dataset.agents.split(',');
1247
1309
  return act(() => Promise.all(ids.map((x) => api('POST', `/agents/${x}/prompt-key`, { key: '1' }))), `Allowed for ${ids.length} agents`);
1248
1310
  }
1311
+ case 'repeat-save': {
1312
+ const r = repeatsState.suggestions.find((x) => `repeat:${x.key}` === key);
1313
+ return r && playbooksUi.open({ name: r.name, intent: r.intent, projects: r.projects });
1314
+ }
1315
+ case 'repeat-dismiss':
1316
+ return act(async () => {
1317
+ repeatsState = await api('POST', '/repeats/dismiss', { key: key.slice('repeat:'.length) });
1318
+ renderInbox();
1319
+ }, "Won't suggest it again");
1320
+ case 'ap-review':
1321
+ showTab('agents');
1322
+ select(id);
1323
+ return reviewUi.open(id);
1324
+ case 'ap-ship':
1325
+ return act(() => api('POST', `/backlog/${key.split(':')[1]}/ship`), 'Shipping: opening its pull request');
1326
+ case 'ap-reopen': {
1327
+ const note = prompt('What still needs doing? (sent to the same agent)', '');
1328
+ if (note === null) return;
1329
+ return act(() => api('POST', `/backlog/${key.split(':')[1]}/reopen`, { note }), 'Sent back');
1330
+ }
1249
1331
  case 'rule-accept':
1250
1332
  case 'rule-dismiss': {
1251
1333
  const [, repo, rule] = key.match(/^rule:(.+?):((?:Bash|WebFetch|mcp__).*)$/) ?? [];
@@ -1736,7 +1818,9 @@ function renderDetail() {
1736
1818
  const u = a.usage ?? { total: 0 };
1737
1819
  const current = a.sessions.find((s) => s.id === a.sessionId);
1738
1820
 
1739
- body.innerHTML = `
1821
+ const changed = morph(
1822
+ body,
1823
+ `
1740
1824
  <div class="d-head">
1741
1825
  <div class="d-title">
1742
1826
  <h2>
@@ -1884,7 +1968,9 @@ function renderDetail() {
1884
1968
  <h3>Notes</h3>
1885
1969
  <textarea class="notes" data-field="notes" placeholder="Anything worth remembering about this agent">${esc(drafts.notes ?? a.notes ?? '')}</textarea>
1886
1970
  </div>
1887
- </div>`;
1971
+ </div>`,
1972
+ );
1973
+ if (!changed) return;
1888
1974
 
1889
1975
  renderDiagrams(body);
1890
1976
  markOverflow(body);
@@ -1899,22 +1985,77 @@ function renderDetail() {
1899
1985
  }
1900
1986
  }
1901
1987
 
1988
+ const VIEWS = {
1989
+ list: () => renderList(),
1990
+ backlog: () => renderBacklog(),
1991
+ land: () => landUi.render(),
1992
+ inbox: () => renderInbox(),
1993
+ race: () => renderRace(),
1994
+ team: () => renderTeam(),
1995
+ doc: () => renderDocDialog(),
1996
+ detail: () => renderDetail(),
1997
+ overlay: () => term.syncOverlay(),
1998
+ };
1999
+ const ALL_VIEWS = Object.keys(VIEWS);
2000
+
1902
2001
  function render() {
1903
- renderList();
1904
- renderBacklog();
1905
- landUi.render();
1906
- renderInbox();
1907
- renderRace();
1908
- renderTeam();
1909
- renderDocDialog();
1910
- renderDetail();
1911
- term.syncOverlay();
2002
+ dirty.clear();
2003
+ perf.draw(ALL_VIEWS);
1912
2004
  }
1913
2005
 
2006
+ // Live updates come many times a second when many agents work. They mark the views they touch,
2007
+ // and those are drawn once in the next frame, so typing never waits behind a run of redraws.
2008
+ const dirty = new Set();
2009
+ let drawQueued = false;
2010
+
2011
+ function invalidate(views = ALL_VIEWS) {
2012
+ for (const v of views) dirty.add(v);
2013
+ if (drawQueued) return;
2014
+ drawQueued = true;
2015
+ const draw = () => {
2016
+ if (!drawQueued) return;
2017
+ drawQueued = false;
2018
+ const views = ALL_VIEWS.filter((v) => dirty.has(v));
2019
+ dirty.clear();
2020
+ perf.draw(views);
2021
+ };
2022
+ requestAnimationFrame(draw);
2023
+ // A background tab gets no frames; it still keeps its title ("(2) needs input") current.
2024
+ setTimeout(draw, 500);
2025
+ }
2026
+
2027
+ // ?perf: how long redraws take, in a corner of the page.
2028
+ const perf = {
2029
+ on: new URLSearchParams(location.search).has('perf'),
2030
+ runs: [], // [at, ms, { view: ms }] over the last 5 seconds
2031
+ draw(views) {
2032
+ if (!this.on) {
2033
+ for (const v of views) VIEWS[v]();
2034
+ return;
2035
+ }
2036
+ const each = {};
2037
+ const t = performance.now();
2038
+ for (const v of views) {
2039
+ const t0 = performance.now();
2040
+ VIEWS[v]();
2041
+ each[v] = performance.now() - t0;
2042
+ }
2043
+ const now = performance.now();
2044
+ this.runs = [...this.runs.filter(([at]) => now - at < 5000), [now, now - t, each]];
2045
+ const ms = this.runs.map(([, d]) => d).sort((x, y) => x - y);
2046
+ const by = {};
2047
+ for (const [, , e] of this.runs) for (const [v, d] of Object.entries(e)) by[v] = (by[v] ?? 0) + d;
2048
+ 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)}%`);
2049
+ const el = $('#perf') ?? document.body.appendChild(Object.assign(document.createElement('div'), { id: 'perf', className: 'perf' }));
2050
+ 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(' ')}`;
2051
+ },
2052
+ };
2053
+
1914
2054
  function select(id) {
1915
2055
  selectedId = id;
1916
- history.replaceState(null, '', id ? `#${id}` : '#');
2056
+ // Closing the terminal puts its agent in the address; the one switched to goes there after.
1917
2057
  if (term.agentId && term.agentId !== id) term.close();
2058
+ history.replaceState(null, '', id ? `#${id}` : '#');
1918
2059
  render();
1919
2060
  }
1920
2061
 
@@ -2019,7 +2160,8 @@ const term = {
2019
2160
  } else if (msg.t === 'error') {
2020
2161
  toast(`Terminal unavailable: ${msg.error}`, 5000);
2021
2162
  } else if (msg.t === 'o') {
2022
- this.xterm.write(msg.d);
2163
+ // Output is most of what arrives (every keystroke's echo): nothing else on the page changes.
2164
+ return this.xterm.write(msg.d);
2023
2165
  } else if (msg.t === 'exit') {
2024
2166
  if (isShell(this.kind)) this.shellExited = true;
2025
2167
  else this.xterm.write(`\r\n\x1b[2m[claude exited with code ${msg.code}; session kept]\x1b[0m\r\n`);
@@ -3066,6 +3208,16 @@ $('#counts').addEventListener('click', (e) => {
3066
3208
  });
3067
3209
  });
3068
3210
 
3211
+ // Improve (agent details, scorecard, backlog, inbox): its scorecard goes back to the agent to fix.
3212
+ document.addEventListener('click', (e) => {
3213
+ const btn = e.target.closest('[data-sc-improve]');
3214
+ if (!btn) return;
3215
+ e.preventDefault();
3216
+ e.stopPropagation();
3217
+ btn.disabled = true;
3218
+ 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));
3219
+ });
3220
+
3069
3221
  $('#agent-list').addEventListener('click', (e) => {
3070
3222
  const rename = e.target.closest('[data-rename]');
3071
3223
  if (rename) {
@@ -3685,6 +3837,7 @@ function handleEvent(msg) {
3685
3837
  for (const t of msg.teams ?? []) teams.set(t.id, t);
3686
3838
  builtReports = msg.built ?? [];
3687
3839
  if (msg.rules) rulesState = msg.rules;
3840
+ if (msg.repeats) repeatsState = msg.repeats;
3688
3841
  builtUi.render();
3689
3842
  msg.agents.forEach(watchDocs);
3690
3843
  if (selectedId && !agents.has(selectedId)) selectedId = null;
@@ -3694,10 +3847,18 @@ function handleEvent(msg) {
3694
3847
  }
3695
3848
  openTermOnLoad = false;
3696
3849
  } else if (msg.t === 'agent') {
3697
- agents.set(msg.agent.id, msg.agent);
3850
+ const id = msg.agent.id;
3851
+ agents.set(id, msg.agent);
3698
3852
  watchDocs(msg.agent);
3699
3853
  agentChanged(msg.agent);
3700
3854
  if (msg.agent.builds) builtUi.sessionChanged(msg.agent);
3855
+ // The detail, terminal and document views show one agent; another agent's update leaves them be.
3856
+ return invalidate([
3857
+ 'list', 'backlog', 'land', 'inbox', 'race', 'team',
3858
+ ...(docView?.agentId === id ? ['doc'] : []),
3859
+ ...(id === selectedId || msg.agent.forkOf?.agentId === selectedId ? ['detail'] : []),
3860
+ ...(id === term.agentId ? ['overlay'] : []),
3861
+ ]);
3701
3862
  } else if (msg.t === 'removed') {
3702
3863
  agents.delete(msg.id);
3703
3864
  if (selectedId === msg.id) select(null);
@@ -3711,6 +3872,8 @@ function handleEvent(msg) {
3711
3872
  teams.delete(msg.id);
3712
3873
  } else if (msg.t === 'rules') {
3713
3874
  rulesState = msg.rules;
3875
+ } else if (msg.t === 'repeats') {
3876
+ repeatsState = msg.repeats;
3714
3877
  } else if (msg.t === 'built') {
3715
3878
  builtReports = msg.reports;
3716
3879
  builtUi.render();
@@ -3720,7 +3883,7 @@ function handleEvent(msg) {
3720
3883
  projectsState = msg.projects;
3721
3884
  renderProjectOptions();
3722
3885
  }
3723
- render();
3886
+ invalidate();
3724
3887
  }
3725
3888
 
3726
3889
  function connectEvents() {