@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.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- 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 & 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|