@staix/agent-hub 0.12.20 → 0.12.22

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -0
package/src/ui/index.html CHANGED
@@ -4,17 +4,27 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>agent-hub | Local dashboard</title>
7
+ <script>
8
+ const dashboardTheme = (() => {
9
+ let choice = 'system';
10
+ try { const saved = document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith('agent-hub-theme='))?.slice('agent-hub-theme='.length); if (['system','light','dark'].includes(saved)) choice = saved; } catch {}
11
+ const apply = () => { document.documentElement.dataset.theme = choice; };
12
+ apply();
13
+ return { get: () => choice, set: (value) => { choice = ['system','light','dark'].includes(value) ? value : 'system'; apply(); try { document.cookie = `agent-hub-theme=${choice}; Path=/; Max-Age=31536000; SameSite=Strict`; } catch {} } };
14
+ })();
15
+ </script>
7
16
  <style>
8
- :root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
17
+ :root{color-scheme:light;--waiting:#795399;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}:root[data-theme="dark"]{color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]):not([data-theme="dark"]){color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
18
+ .task-progress{padding:12px 18px;border-bottom:1px solid var(--line)}.task-progress svg{display:block;width:100%;height:12px;margin:8px 0}.progress-proposed{fill:var(--dot)}.progress-unknown{fill:var(--dot)}.progress-waiting{fill:var(--waiting)}.progress-in_progress{fill:var(--blue)}.progress-in_review{fill:var(--amber)}.progress-changes_requested{fill:var(--red)}.progress-approved{fill:var(--green)}.task-stage{font:12px ui-monospace,monospace;margin:8px 0;color:var(--muted)}.task-stage .reached{color:var(--green)}.task-stage.changes{color:var(--red)}.theme-choice{width:auto}
9
19
  </style>
10
20
  </head>
11
21
  <body>
12
- <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div id="connection" class="connection" role="status">Connecting...</div></header>
22
+ <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div><label for="theme">Theme</label><select id="theme" class="theme-choice"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><div id="connection" class="connection" role="status">Connecting...</div></div></header>
13
23
  <main>
14
24
  <p id="notice" class="notice" role="status" aria-live="polite"></p>
15
25
  <div class="stack">
16
26
  <section aria-labelledby="stream-title"><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
17
- <section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="proposed">Proposed</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
27
+ <section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="proposed">Proposed</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
18
28
  <section aria-labelledby="compose-title"><div class="section-head"><h2 id="compose-title">Console message</h2></div><form id="send" class="content"><div class="field"><label for="recipient">Recipient</label><select id="recipient" name="recipient"><option value="">All peers</option></select></div><div class="field"><label for="message">Message</label><textarea id="message" name="body" required maxlength="8000" placeholder="Send an instruction to the team..."></textarea></div><button class="primary" data-mutation="project" type="submit">Send message</button></form></section>
19
29
  </div>
20
30
  <div class="stack">
@@ -22,11 +32,14 @@
22
32
  <section aria-labelledby="peers-title"><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"></div></section>
23
33
  <section aria-labelledby="permissions-title"><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
24
34
  <section aria-labelledby="budget-title"><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div></section>
35
+ <section aria-labelledby="bench-title"><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
25
36
  </div>
26
37
  </main>
27
38
  <footer>Loopback session. Private task content stays in the terminal. Reopen with <code>ahub ui</code> when your session expires.</footer>
28
39
  <script>
29
40
  'use strict';
41
+ /* TASK_PROGRESS_MODEL */
42
+ const themeControl = document.getElementById('theme'); themeControl.value = dashboardTheme.get(); themeControl.addEventListener('change', () => dashboardTheme.set(themeControl.value));
30
43
  let ticket = location.hash.slice(1);
31
44
  history.replaceState(null, '', location.pathname);
32
45
  const $ = (id) => document.getElementById(id);
@@ -98,7 +111,7 @@ function resetView(id, instance) {
98
111
  signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
99
112
  if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
100
113
  $('stream-empty').hidden = true; $('task-filter').value = '';
101
- for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
114
+ for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
102
115
  $('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
103
116
  notice('');
104
117
  draftChoicesPending = managerMode && Boolean(selectedProjectId);
@@ -146,6 +159,39 @@ async function refreshProjects() {
146
159
  if (!stopped) projectsTimer = setTimeout(refreshProjects, 3000);
147
160
  }
148
161
  function peerOptions(select, ids, first) { const selected = select.value; select.replaceChildren(); const initial = el('option', first); initial.value = ''; select.append(initial); for (const id of ids) { const option = el('option', id); option.value = id; select.append(option); } if (ids.includes(selected)) select.value = selected; }
162
+ function renderTaskProgress(progress) {
163
+ update('task-progress', progress, (target) => {
164
+ const percent = progress.total ? Math.floor(progress.counts.approved * 100 / progress.total) : 0;
165
+ target.append(el('strong', `${progress.counts.approved}/${progress.total} approved (${percent}%)`));
166
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 100 12'); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('aria-hidden', 'true');
167
+ let x = 0;
168
+ for (const [state, count] of Object.entries(progress.counts)) {
169
+ if (!count) continue;
170
+ const width = progress.total ? count / progress.total * 100 : 0;
171
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', String(x)); rect.setAttribute('y', '0'); rect.setAttribute('width', String(width)); rect.setAttribute('height', '12'); rect.setAttribute('class', 'progress-' + state); svg.append(rect); x += width;
172
+ }
173
+ target.append(svg, el('p', Object.entries(progress.counts).map(([state, count]) => state.replaceAll('_', ' ') + ' ' + count).join(' / '), 'meta'));
174
+ });
175
+ }
176
+ function renderTaskStage(stage) {
177
+ const row = el('div', undefined, 'task-stage' + (stage.changes ? ' changes' : ''));
178
+ for (const [index, label] of ['Proposed', 'In progress', 'In review', 'Approved'].entries()) row.append(el('span', `${index < stage.stage ? '[x]' : '[ ]'} ${label}${index < 3 ? ' > ' : ''}`, index < stage.stage ? 'reached' : ''));
179
+ row.append(el('span', ` / Stage ${stage.stage}/4: ${stage.label}`)); return row;
180
+ }
181
+ function renderApprovals(permissions) {
182
+ const terminalWaiting = permissions.filter((permission) => permission.peer === 'pi' || permission.peer === 'local').length;
183
+ $('permission-count').textContent = permissions.length + ' waiting' + (terminalWaiting ? ', ' + terminalWaiting + ' Pi/local: answer in the console or ahub tail / ahub permit' : '');
184
+ update('permissions', permissions, (target) => {
185
+ if (!permissions.length) return empty(target, 'No approvals waiting.');
186
+ for (const permission of permissions) {
187
+ const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
188
+ if (permission.terminalOnly) row.append(el('p', 'Review in the console or ahub tail; answer with ahub permit. You can deny here.', 'meta'));
189
+ const actions = el('div', undefined, 'actions');
190
+ for (const option of permission.options) { const deny = option.kind === 'reject_once' || option.kind === 'reject_always'; if (permission.terminalOnly && !deny) continue; actions.append(button(option.name, {action:'permit', id:permission.id, option:option.optionId}, deny ? 'danger' : 'primary')); }
191
+ row.append(actions); target.append(row);
192
+ }
193
+ });
194
+ }
149
195
  function render(snapshot) {
150
196
  const peers = snapshot.status.peers || {}, ids = Object.keys(peers);
151
197
  $('peer-count').textContent = ids.length + ' peers';
@@ -172,16 +218,20 @@ function render(snapshot) {
172
218
  const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
173
219
  const peerSignature = JSON.stringify([ids, assignable]);
174
220
  if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), assignable, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
221
+ const progress = taskProgress(snapshot.tasks);
222
+ renderTaskProgress(progress);
223
+ const progressStages = new Map(progress.stages.map(stage => [stage.id, stage]));
175
224
  $('task-count').textContent = snapshot.tasks.length + ' tasks';
176
225
  const taskFilter = $('task-filter').value;
177
226
  const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;
178
- update('tasks', [visibleTasks, ids, taskFilter, snapshot.tasks.length], (target) => {
227
+ update('tasks', [visibleTasks, ids, taskFilter, progress], (target) => {
179
228
  if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
180
229
  if (!visibleTasks.length) return empty(target, 'No tasks in this state.');
181
230
  for (const task of visibleTasks) {
182
231
  const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
183
232
  head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge(task.state.replaceAll('_', ' '), taskBadge[task.state] || ''));
184
- row.append(head, el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
233
+ const stage = progressStages.get(task.id);
234
+ row.append(head, renderTaskStage(stage), el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
185
235
  if (task.detail) row.append(el('p', task.detail, 'body'));
186
236
  if (task.state !== 'approved' && ids.length) {
187
237
  const actions = el('div', undefined, 'actions'), select = el('select'); select.setAttribute('aria-label', 'Assign task ' + task.id); peerOptions(select, ids, 'Select peer'); if (ids.includes(task.owner)) select.value = task.owner;
@@ -191,17 +241,7 @@ function render(snapshot) {
191
241
  target.append(row);
192
242
  }
193
243
  });
194
- $('permission-count').textContent = String(snapshot.permissions.length);
195
- update('permissions', snapshot.permissions, (target) => {
196
- if (!snapshot.permissions.length) return empty(target, 'No approvals waiting.');
197
- for (const permission of snapshot.permissions) {
198
- const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
199
- if (permission.terminalOnly) row.append(el('p', 'Review details and allow in the terminal with ahub tail. You can deny here.', 'meta'));
200
- const actions = el('div', undefined, 'actions');
201
- for (const option of permission.options) { const deny = option.kind === 'reject_once' || option.kind === 'reject_always'; if (permission.terminalOnly && !deny) continue; actions.append(button(option.name, {action:'permit', id:permission.id, option:option.optionId}, deny ? 'danger' : 'primary')); }
202
- row.append(actions); target.append(row);
203
- }
204
- });
244
+ renderApprovals(snapshot.permissions);
205
245
  update('budget', snapshot.budget, (target) => {
206
246
  if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
207
247
  for (const [peer, budget] of Object.entries(snapshot.budget)) {
@@ -226,6 +266,7 @@ function render(snapshot) {
226
266
  target.append(row);
227
267
  }
228
268
  });
269
+ renderBench(snapshot.bench);
229
270
  const stream = $('stream');
230
271
  if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
231
272
  for (const entry of snapshot.events || []) {
@@ -263,6 +304,46 @@ const STREAM_KINDS = ['chat', 'task', 'review', 'status', 'budget', 'presence',
263
304
  let streamFilter = '';
264
305
  function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
265
306
  function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
307
+ // #251: benchmark runs on this machine and arm comparisons per suite. Every bar carries its number as text.
308
+ function renderBench(bench) {
309
+ const pct = (v) => v === null || v === undefined ? '-' : Math.round(v * 100) + '%';
310
+ const num = (v) => v === null || v === undefined ? '-' : String(Math.round(v));
311
+ const dur = (ms) => ms === null || ms === undefined ? '-' : ms < 120000 ? Math.round(ms / 1000) + 's' : Math.round(ms / 60000) + 'm';
312
+ $('bench-count').textContent = bench ? bench.runs.length + ' runs' : '';
313
+ update('bench', bench || null, (target) => {
314
+ if (!bench) return empty(target, 'No benchmark runs. In a project kept for benchmarks, run ahub bench run <suite.json> --arm <label>.');
315
+ for (const run of bench.runs) {
316
+ const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
317
+ const tone = run.state === 'running' ? 'info' : run.state === 'finished' ? 'ok' : run.state === 'unknown' ? '' : 'warn';
318
+ head.append(el('span', run.suite + ' [' + run.arm + ']', 'label'), el('span', run.state, 'badge ' + tone));
319
+ const bar = el('progress'); bar.max = run.total || 1; bar.value = run.done; bar.setAttribute('aria-label', run.suite + ' ' + run.arm + ': ' + run.done + ' of ' + run.total + ' attempts');
320
+ row.append(head);
321
+ if (run.state === 'running') row.append(el('p', 'now: ' + (run.current || '-') + ' / running ' + dur(Date.now() - Date.parse(run.startedAt)) + ' / ' + (run.total - run.done) + ' attempts left', 'meta'));
322
+ row.append(bar, el('p', run.done + '/' + run.total + ' attempts / pass ' + pct(run.passRate) + ' (' + run.pass + ' pass, ' + run.fail + ' fail, ' + run.timeout + ' timeout, ' + run.error + ' error) / tokens p50 ' + num(run.tokensMedian) + ' / wall p50 ' + dur(run.wallMsMedian), 'meta'));
323
+ target.append(row);
324
+ }
325
+ const suites = new Map();
326
+ for (const arm of bench.arms) { if (!suites.has(arm.suite)) suites.set(arm.suite, []); suites.get(arm.suite).push(arm); }
327
+ for (const [suite, arms] of suites) {
328
+ if (arms.length < 2) continue;
329
+ const block = el('div', undefined, 'row');
330
+ block.append(el('div', 'Compare arms: ' + suite, 'label'));
331
+ for (const [key, label, rate, fmt] of [['passRate', 'Pass rate', true, pct], ['firstPassRate', 'First pass', true, pct], ['tokensMedian', 'Tokens per attempt (p50)', false, num], ['wallMsMedian', 'Wall time per attempt (p50)', false, dur]]) {
332
+ const values = arms.map((a) => a.measures[key]).filter((v) => v !== null && v !== undefined);
333
+ const max = rate ? 1 : Math.max(1, ...values);
334
+ block.append(el('p', label, 'meta'));
335
+ for (const arm of arms) {
336
+ const value = arm.measures[key];
337
+ const counted = key === 'passRate' || key === 'wallMsMedian' ? arm.measures.scored : arm.measures.measured;
338
+ const line = el('p', arm.arm + ': ' + fmt(value) + ' (' + counted + ' counted of ' + arm.measures.attempts + ' attempts, ' + arm.runs + ' runs)' + (counted < bench.minAttempts ? ', too few to compare' : ''), 'meta');
339
+ const bar = el('progress'); bar.max = max; bar.value = value === null || value === undefined ? 0 : value; bar.setAttribute('aria-label', label + ', ' + arm.arm + ': ' + fmt(value));
340
+ block.append(line, bar);
341
+ }
342
+ }
343
+ target.append(block);
344
+ }
345
+ });
346
+ }
266
347
  function renderStreamChips() {
267
348
  const group = $('stream-filters');
268
349
  if (group.children.length) return;
@@ -0,0 +1,33 @@
1
+ /** Public structural fields only. Titles, details and history never enter progress calculations. */
2
+ export interface PublicProgressTask {
3
+ readonly id: number;
4
+ readonly state: string;
5
+ readonly deps?: readonly number[];
6
+ }
7
+ export interface ProgressStage {
8
+ id: number; stage: number; waiting: boolean; changes: boolean; label: string;
9
+ }
10
+ export interface TaskProgress {
11
+ total: number;
12
+ counts: { proposed: number; waiting: number; in_progress: number; in_review: number; changes_requested: number; approved: number; unknown?: number };
13
+ stages: ProgressStage[];
14
+ }
15
+ /** Self-contained so the dashboard executes this exact function inside its hashed inline script. */
16
+ export function taskProgress(tasks: readonly PublicProgressTask[]): TaskProgress {
17
+ const states = new Map(tasks.map(task => [task.id, task.state]));
18
+ const counts: TaskProgress["counts"] = { proposed: 0, waiting: 0, in_progress: 0, in_review: 0, changes_requested: 0, approved: 0 };
19
+ const known = ["proposed", "in_progress", "in_review", "changes_requested", "approved"];
20
+ const stages = tasks.map(task => {
21
+ if (!known.includes(task.state)) {
22
+ counts.unknown = (counts.unknown ?? 0) + 1;
23
+ return { id: task.id, stage: 0, waiting: false, changes: false, label: "unknown state" };
24
+ }
25
+ const waiting = task.state === "proposed" && (task.deps ?? []).some(id => states.get(id) !== "approved");
26
+ if (waiting) counts.waiting++; else { const key = task.state as keyof TaskProgress["counts"]; counts[key] = (counts[key] ?? 0) + 1; }
27
+ const changes = task.state === "changes_requested";
28
+ const stage = task.state === "approved" ? 4 : task.state === "in_review" ? 3 : task.state === "in_progress" || changes ? 2 : 1;
29
+ const label = waiting ? "waiting on dependencies" : changes ? "changes requested (back in progress)" : task.state.replaceAll("_", " ");
30
+ return { id: task.id, stage, waiting, changes, label };
31
+ });
32
+ return { total: tasks.length, counts, stages };
33
+ }