@staix/agent-hub 0.12.21 → 0.12.23
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 +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +32 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +483 -83
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +7 -5
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +43 -4
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
package/src/ui/index.html
CHANGED
|
@@ -15,7 +15,7 @@ const dashboardTheme = (() => {
|
|
|
15
15
|
</script>
|
|
16
16
|
<style>
|
|
17
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}
|
|
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}.actions input[type="text"]{width:auto;flex:1 1 160px;max-width:320px}details.settings-group>summary{padding:12px 18px;cursor:pointer;border-bottom:1px solid var(--line);font-weight:600}details.settings-group:last-child:not([open])>summary{border-bottom:0}.preview{margin-top:8px;padding:8px 10px;background:var(--panel-soft);border-radius:5px;font:12px ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.preview:empty{display:none}
|
|
19
19
|
</style>
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
@@ -26,6 +26,7 @@ const dashboardTheme = (() => {
|
|
|
26
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>
|
|
27
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>
|
|
28
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>
|
|
29
|
+
<section aria-labelledby="settings-title"><div class="section-head"><h2 id="settings-title">Settings</h2><span id="settings-session" class="meta" role="status"></span></div><div id="settings"><p class="empty">Waiting for the hub...</p></div></section>
|
|
29
30
|
</div>
|
|
30
31
|
<div class="stack">
|
|
31
32
|
<section id="projects-panel" aria-labelledby="projects-title"><div class="section-head"><h2 id="projects-title">Projects</h2><span id="project-count" class="meta"></span></div><div id="projects"></div></section>
|
|
@@ -39,6 +40,7 @@ const dashboardTheme = (() => {
|
|
|
39
40
|
<script>
|
|
40
41
|
'use strict';
|
|
41
42
|
/* TASK_PROGRESS_MODEL */
|
|
43
|
+
/* PEER_START_RETRY_MS */
|
|
42
44
|
const themeControl = document.getElementById('theme'); themeControl.value = dashboardTheme.get(); themeControl.addEventListener('change', () => dashboardTheme.set(themeControl.value));
|
|
43
45
|
let ticket = location.hash.slice(1);
|
|
44
46
|
history.replaceState(null, '', location.pathname);
|
|
@@ -49,6 +51,7 @@ let managerMode = false, projectList = [], selectedProjectId = '', selectedInsta
|
|
|
49
51
|
let lastSnapshot = null;
|
|
50
52
|
const drafts = new Map();
|
|
51
53
|
const pendingButtons = new WeakSet();
|
|
54
|
+
const pendingStarts = new Map();
|
|
52
55
|
let draftChoicesPending = false;
|
|
53
56
|
const signatures = new Map();
|
|
54
57
|
const time = (value) => value ? new Date(value).toLocaleString() : 'unknown';
|
|
@@ -72,22 +75,47 @@ async function post(path, body) {
|
|
|
72
75
|
const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
73
76
|
if (response.status === 401) { expire(); throw new Error('Run ahub ui to start a new session.'); }
|
|
74
77
|
const result = await response.json();
|
|
78
|
+
if (result.unconfirmed === true) return result;
|
|
75
79
|
if (!response.ok || result.ok === false) throw new Error(result.error || 'Request failed (' + response.status + ').');
|
|
76
80
|
return result;
|
|
77
81
|
}
|
|
78
82
|
function canMutate() { return !managerMode || snapshotValid; }
|
|
79
83
|
function context() { return { projectId: selectedProjectId, instanceId: selectedInstanceId, generation: viewGeneration }; }
|
|
84
|
+
function startKey(peer, projectId = selectedProjectId, instanceId = selectedInstanceId) { return JSON.stringify([projectId, instanceId, peer]); }
|
|
85
|
+
function clearPendingStart(key) { const pending = pendingStarts.get(key); if (pending) { clearTimeout(pending.timer); pendingStarts.delete(key); } }
|
|
86
|
+
function redrawStartControls() { if (stopped) return; syncMutationControls(); if (snapshotValid && lastSnapshot) render(lastSnapshot); }
|
|
80
87
|
async function action(payload, button, managerAction = false) {
|
|
81
88
|
if (stopped) return false;
|
|
82
89
|
if (!managerAction && !canMutate()) { notice('Wait for a current project snapshot before changing it.', true); return false; }
|
|
83
90
|
const captured = context();
|
|
91
|
+
const key = payload.action === 'start_peer' ? startKey(payload.peer, captured.projectId, captured.instanceId) : undefined;
|
|
92
|
+
if (key && pendingStarts.has(key)) return false;
|
|
93
|
+
const pending = key ? {phase:'pending', text:'Starting ' + payload.peer + '. Waiting for the hub result.'} : undefined;
|
|
94
|
+
if (key) pendingStarts.set(key, pending);
|
|
84
95
|
if (managerMode && !managerAction) payload = {...payload, projectId: captured.projectId, instanceId: captured.instanceId};
|
|
85
96
|
if (button) { pendingButtons.add(button); button.disabled = true; }
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
if (key) redrawStartControls();
|
|
98
|
+
try {
|
|
99
|
+
const result = await post('/action', payload);
|
|
100
|
+
if (result.unconfirmed === true) {
|
|
101
|
+
if (key && pendingStarts.get(key) === pending) {
|
|
102
|
+
if (managerMode) {
|
|
103
|
+
pending.phase = 'unconfirmed'; pending.retryAt = Date.now() + PEER_START_RETRY_MS;
|
|
104
|
+
pending.text = (result.text || 'Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.') + ' Retry becomes available ' + Math.ceil(PEER_START_RETRY_MS / 1000) + ' seconds after this unconfirmed result unless the peer attaches sooner.';
|
|
105
|
+
pending.timer = setTimeout(() => { if (pendingStarts.get(key) === pending) { clearPendingStart(key); redrawStartControls(); } }, PEER_START_RETRY_MS);
|
|
106
|
+
} else clearPendingStart(key);
|
|
107
|
+
}
|
|
108
|
+
if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.');
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (key && pendingStarts.get(key) === pending) clearPendingStart(key);
|
|
112
|
+
if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.');
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch (error) { if (key && pendingStarts.get(key) === pending) clearPendingStart(key); if (!stopped && (!managerMode || captured.generation === viewGeneration)) notice(error.message, true); return false; }
|
|
116
|
+
finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = Boolean(key && pendingStarts.has(key)) || (!managerAction && managerMode && !snapshotValid); } if (key) redrawStartControls(); }
|
|
89
117
|
}
|
|
90
|
-
function button(text, payload, cls, managerAction = false) { const node = el('button', text, cls); node.type = 'button'; node.dataset.mutation = managerAction ? 'manager' : 'project'; node.disabled = !managerAction && managerMode && !snapshotValid; node.addEventListener('click', () => void action(payload, node, managerAction)); return node; }
|
|
118
|
+
function button(text, payload, cls, managerAction = false) { const node = el('button', text, cls); node.type = 'button'; node.dataset.mutation = managerAction ? 'manager' : 'project'; if (payload.action === 'start_peer') node.dataset.startKey = startKey(payload.peer); node.disabled = Boolean(node.dataset.startKey && pendingStarts.has(node.dataset.startKey)) || (!managerAction && managerMode && !snapshotValid); node.addEventListener('click', () => void action(payload, node, managerAction)); return node; }
|
|
91
119
|
function update(id, data, render) { const signature = JSON.stringify(data); if (signatures.get(id) === signature) return; signatures.set(id, signature); const target = $(id); target.replaceChildren(); render(target); }
|
|
92
120
|
function empty(target, text) { target.append(el('p', text, 'empty')); }
|
|
93
121
|
function count(value) { return Array.isArray(value) ? value.length : value && typeof value === 'object' ? Object.keys(value).length : value; }
|
|
@@ -104,14 +132,15 @@ function restoreDraft(choices = false) {
|
|
|
104
132
|
for (const id of ['recipient','owner']) $(id).value = Array.from($(id).options).some((o) => o.value === draft[id]) ? draft[id] : '';
|
|
105
133
|
} else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
|
|
106
134
|
}
|
|
107
|
-
function syncMutationControls() { document.querySelectorAll('button[data-mutation="project"]').forEach((b) => { b.disabled = pendingButtons.has(b) || (managerMode && !snapshotValid); }); }
|
|
135
|
+
function syncMutationControls() { document.querySelectorAll('button[data-mutation="project"]').forEach((b) => { b.disabled = pendingButtons.has(b) || Boolean(b.dataset.startKey && pendingStarts.has(b.dataset.startKey)) || (managerMode && !snapshotValid); }); }
|
|
108
136
|
function resetView(id, instance) {
|
|
109
137
|
saveDraft();
|
|
110
138
|
selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
|
|
111
139
|
signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
|
|
112
140
|
if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
|
|
113
141
|
$('stream-empty').hidden = true; $('task-filter').value = '';
|
|
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); }
|
|
142
|
+
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.'],['settings','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
|
|
143
|
+
$('settings-session').textContent = '';
|
|
115
144
|
$('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
|
|
116
145
|
notice('');
|
|
117
146
|
draftChoicesPending = managerMode && Boolean(selectedProjectId);
|
|
@@ -178,11 +207,141 @@ function renderTaskStage(stage) {
|
|
|
178
207
|
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
208
|
row.append(el('span', ` / Stage ${stage.stage}/4: ${stage.label}`)); return row;
|
|
180
209
|
}
|
|
210
|
+
function renderApprovals(permissions) {
|
|
211
|
+
const terminalWaiting = permissions.filter((permission) => permission.peer === 'pi' || permission.peer === 'local').length;
|
|
212
|
+
$('permission-count').textContent = permissions.length + ' waiting' + (terminalWaiting ? ', ' + terminalWaiting + ' Pi/local: answer in the console or ahub tail / ahub permit' : '');
|
|
213
|
+
update('permissions', permissions, (target) => {
|
|
214
|
+
if (!permissions.length) return empty(target, 'No approvals waiting.');
|
|
215
|
+
for (const permission of permissions) {
|
|
216
|
+
const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
|
|
217
|
+
if (permission.terminalOnly) row.append(el('p', 'Review in the console or ahub tail; answer with ahub permit. You can deny here.', 'meta'));
|
|
218
|
+
const actions = el('div', undefined, 'actions');
|
|
219
|
+
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')); }
|
|
220
|
+
row.append(actions); target.append(row);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const SETTING_GROUPS = ['Permissions', 'Start', 'Routing', 'Switches'], INHERIT = '__inherit__';
|
|
225
|
+
const settingsOpen = new Set(['Permissions']);
|
|
226
|
+
const settingText = (value) => value === null || value === undefined ? '(not set)' : Array.isArray(value) ? (value.join(', ') || '(none)') : String(value);
|
|
227
|
+
async function previewSetting(row, value, out, control) {
|
|
228
|
+
if (stopped) return;
|
|
229
|
+
if (!canMutate()) return notice('Wait for a current project snapshot before previewing a change.', true);
|
|
230
|
+
const captured = context();
|
|
231
|
+
let payload = {action:'setting_preview', key:row.key, value};
|
|
232
|
+
if (managerMode) payload = {...payload, projectId: captured.projectId, instanceId: captured.instanceId};
|
|
233
|
+
control.disabled = true;
|
|
234
|
+
try {
|
|
235
|
+
const result = await post('/action', payload), lines = [...(result.lines || [])];
|
|
236
|
+
// Each side is route explain: the task line, "if it were assigned now:", then the trace.
|
|
237
|
+
for (const task of result.tasks || []) lines.push('', task.before[0], ' before:', ...task.before.slice(2).map((line) => ' ' + line), ' after:', ...task.after.slice(2).map((line) => ' ' + line));
|
|
238
|
+
if (result.hidden) lines.push('', result.hidden + ' more open tasks are not shown.');
|
|
239
|
+
out.textContent = lines.join('\n');
|
|
240
|
+
} catch (error) { out.textContent = error.message; }
|
|
241
|
+
finally { if (!stopped) control.disabled = false; }
|
|
242
|
+
}
|
|
243
|
+
function settingRow(row, raise) {
|
|
244
|
+
const node = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), id = 'setting-' + row.key.replaceAll('.', '-');
|
|
245
|
+
const title = el('label', row.label, 'label'); title.htmlFor = id;
|
|
246
|
+
head.append(title, badge(settingText(row.value), row.group === 'Permissions' && row.value !== null && row.value !== 'ask' ? 'warn' : ''));
|
|
247
|
+
node.append(head, el('p', row.key + ' / from ' + row.source + ' / ' + (row.applies === 'live' ? 'applies at once' : row.applies === 'peer start' ? "applies at the peer's next start" : 'read at hub start') + ' / written to ' + row.file, 'meta'));
|
|
248
|
+
if (row.pending !== undefined) node.append(el('p', 'Pending restart: ' + settingText(row.pending) + ' at the next hub start.', 'meta'));
|
|
249
|
+
if (row.hint) node.append(el('p', 'Bounds: ' + row.hint, 'meta'));
|
|
250
|
+
if (row.note) node.append(el('p', row.note, 'meta'));
|
|
251
|
+
if (row.risk === 'raises' && !raise) node.append(el('p', 'Raising this needs a settings session: run ahub ui --settings.' + (row.floor === undefined ? '' : ' Setting it to ' + row.floor + ' works here.'), 'meta'));
|
|
252
|
+
const actions = el('div', undefined, 'actions'), stored = row.file !== 'this hub run';
|
|
253
|
+
let read, confirm;
|
|
254
|
+
if (row.type === 'peers') {
|
|
255
|
+
const input = el('input'); input.type = 'text'; input.id = id; input.value = Array.isArray(row.value) ? row.value.join(', ') : ''; input.placeholder = 'peer ids, comma-separated; empty inherits'; input.autocomplete = 'off'; input.spellcheck = false;
|
|
256
|
+
read = () => { const list = input.value.split(',').map((value) => value.trim()).filter(Boolean); return list.length ? list : null; };
|
|
257
|
+
actions.append(input);
|
|
258
|
+
} else {
|
|
259
|
+
const select = el('select'), values = row.type === 'boolean' ? ['true', 'false'] : (row.values || []), current = row.value === null ? '' : String(row.value); select.id = id;
|
|
260
|
+
if (current && !values.includes(current)) { const option = el('option', current + ' (now)'); option.value = current; option.disabled = true; select.append(option); }
|
|
261
|
+
for (const value of values) { const option = el('option', value); option.value = value; select.append(option); }
|
|
262
|
+
if (stored) { const option = el('option', 'inherit (remove the machine-local value)'); option.value = INHERIT; select.append(option); }
|
|
263
|
+
select.value = current || (stored ? INHERIT : 'ask');
|
|
264
|
+
read = () => select.value === INHERIT ? null : row.type === 'boolean' ? select.value === 'true' : select.value;
|
|
265
|
+
actions.append(select);
|
|
266
|
+
if (row.group === 'Permissions') {
|
|
267
|
+
// never-ask: the person types the peer it is for, as the console asks for y. Removing the machine-local value
|
|
268
|
+
// can leave a never-ask from the shared file in force, so inherit offers the same field.
|
|
269
|
+
const peer = row.key.split('.').pop();
|
|
270
|
+
confirm = el('input'); confirm.type = 'text'; confirm.placeholder = 'type ' + peer + ' to confirm never-ask'; confirm.setAttribute('aria-label', 'Type ' + peer + ' to confirm never-ask for ' + peer); confirm.autocomplete = 'off'; confirm.spellcheck = false;
|
|
271
|
+
const removable = row.source === 'config.local.json'; // inherit changes something only where the machine-local file holds a value
|
|
272
|
+
const sync = () => { confirm.hidden = select.value !== 'never-ask' && !(select.value === INHERIT && removable); }; select.addEventListener('change', sync); sync();
|
|
273
|
+
actions.append(confirm);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const out = el('div', undefined, 'preview'); out.setAttribute('role', 'status'); out.setAttribute('aria-live', 'polite');
|
|
277
|
+
if (row.group === 'Permissions' || row.group === 'Routing') {
|
|
278
|
+
const preview = el('button', 'Preview'); preview.type = 'button'; preview.setAttribute('aria-label', 'Preview ' + row.label);
|
|
279
|
+
preview.addEventListener('click', () => void previewSetting(row, read(), out, preview)); actions.append(preview);
|
|
280
|
+
}
|
|
281
|
+
const save = el('button', 'Save', 'primary'); save.type = 'button'; save.dataset.mutation = 'project'; save.disabled = managerMode && !snapshotValid; save.setAttribute('aria-label', 'Save ' + row.label);
|
|
282
|
+
save.addEventListener('click', () => { const payload = {action:'setting', key:row.key, value:read()}; if (confirm && !confirm.hidden && confirm.value.trim()) payload.confirm = confirm.value.trim(); void action(payload, save); });
|
|
283
|
+
actions.append(save); node.append(actions, out);
|
|
284
|
+
return node;
|
|
285
|
+
}
|
|
286
|
+
const settingSlots = new Map();
|
|
287
|
+
function setText(node, text) { if (node.textContent !== text) node.textContent = text; }
|
|
288
|
+
function renderSettings(settings) {
|
|
289
|
+
if (!settings || !Array.isArray(settings.rows)) { setText($('settings-session'), ''); settingSlots.clear(); return update('settings', 'none', (target) => empty(target, 'This hub does not report settings.')); }
|
|
290
|
+
const raise = typeof settings.sessionUntil === 'number' && settings.sessionUntil > Date.now();
|
|
291
|
+
// Written only when it changes: a status region that is rewritten every second is read out every second.
|
|
292
|
+
setText($('settings-session'), raise ? 'Settings session: ' + Math.max(1, Math.ceil((settings.sessionUntil - Date.now()) / 60000)) + ' min left' : 'Read, lower and safe switches only');
|
|
293
|
+
// The frame (the groups) follows the list of keys. The undo row and every setting sit in slots of their own and
|
|
294
|
+
// are rebuilt only when their own data changed, so a value being typed in one row survives a save of another.
|
|
295
|
+
update('settings', settings.rows.map((row) => row.key), (target) => {
|
|
296
|
+
settingSlots.clear();
|
|
297
|
+
const undo = el('div'); settingSlots.set('', { node: undo, signature: null }); target.append(undo);
|
|
298
|
+
for (const group of SETTING_GROUPS) {
|
|
299
|
+
const rows = settings.rows.filter((row) => row.group === group);
|
|
300
|
+
if (!rows.length) continue;
|
|
301
|
+
const box = el('details', undefined, 'settings-group'); box.open = settingsOpen.has(group);
|
|
302
|
+
box.addEventListener('toggle', () => { if (box.open) settingsOpen.add(group); else settingsOpen.delete(group); });
|
|
303
|
+
box.append(el('summary', group + ' (' + rows.length + ')'));
|
|
304
|
+
for (const row of rows) { const slot = el('div'); settingSlots.set(row.key, { node: slot, signature: '' }); box.append(slot); }
|
|
305
|
+
target.append(box);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
const undoSlot = settingSlots.get(''), undoKey = settings.undo || '';
|
|
309
|
+
if (undoSlot && undoSlot.signature !== undoKey) {
|
|
310
|
+
undoSlot.signature = undoKey; undoSlot.node.replaceChildren();
|
|
311
|
+
if (undoKey) {
|
|
312
|
+
const row = el('div', undefined, 'row'); row.append(el('p', 'Last settings write: ' + undoKey, 'meta'));
|
|
313
|
+
const actions = el('div', undefined, 'actions'); actions.append(button('Undo last write', {action:'setting_undo'})); row.append(actions); undoSlot.node.append(row);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const row of settings.rows) {
|
|
317
|
+
const slot = settingSlots.get(row.key), signature = JSON.stringify([row, raise]);
|
|
318
|
+
if (!slot || slot.signature === signature) continue;
|
|
319
|
+
slot.signature = signature; slot.node.replaceChildren(settingRow(row, raise));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function syncStarts(snapshot) {
|
|
323
|
+
for (const start of Array.isArray(snapshot.starts) ? snapshot.starts : []) if (start.attached) clearPendingStart(startKey(start.peer));
|
|
324
|
+
}
|
|
325
|
+
function renderStarts(target, starts) {
|
|
326
|
+
// A Start control per peer that is not attached: the mode it will use and where that comes from.
|
|
327
|
+
for (const start of starts) {
|
|
328
|
+
if (start.attached) { clearPendingStart(startKey(start.peer)); continue; }
|
|
329
|
+
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
330
|
+
head.append(el('span', 'Start ' + start.peer, 'label'), badge(start.mode));
|
|
331
|
+
const tui = start.mode === 'tui';
|
|
332
|
+
row.append(head, el('p', !tui ? 'Starts headless: its start mode.' : start.via ? 'Start mode tui: opens ' + start.command + ' in a terminal through ' + start.via + '.' : 'Start mode tui, and the hub has no terminal to open (' + start.why + '). Run ' + start.command + ' in a terminal.', 'meta'));
|
|
333
|
+
if (!tui || start.via) { const actions = el('div', undefined, 'actions'); actions.append(button('Start ' + start.peer, {action:'start_peer', peer:start.peer})); row.append(actions); }
|
|
334
|
+
const pending = pendingStarts.get(startKey(start.peer));
|
|
335
|
+
if (pending) row.append(el('p', pending.text, 'meta'));
|
|
336
|
+
target.append(row);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
181
339
|
function render(snapshot) {
|
|
182
|
-
const peers = snapshot.status.peers || {}, ids = Object.keys(peers);
|
|
340
|
+
const peers = snapshot.status.peers || {}, ids = Object.keys(peers), starts = Array.isArray(snapshot.starts) ? snapshot.starts : [];
|
|
183
341
|
$('peer-count').textContent = ids.length + ' peers';
|
|
184
|
-
|
|
185
|
-
|
|
342
|
+
syncStarts(snapshot);
|
|
343
|
+
update('peers', [peers, starts, starts.map(start => { const pending = pendingStarts.get(startKey(start.peer)); return pending ? [pending.phase, pending.retryAt || null] : null; })], (target) => {
|
|
344
|
+
if (!ids.length) { empty(target, 'No peers connected. Start one below, or from your terminal.'); return renderStarts(target, starts); }
|
|
186
345
|
for (const [id, peer] of Object.entries(peers)) {
|
|
187
346
|
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
|
|
188
347
|
name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
|
|
@@ -200,6 +359,7 @@ function render(snapshot) {
|
|
|
200
359
|
const paused = peer.state === 'paused' || peer.paused;
|
|
201
360
|
row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
|
|
202
361
|
}
|
|
362
|
+
renderStarts(target, starts);
|
|
203
363
|
});
|
|
204
364
|
const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
|
|
205
365
|
const peerSignature = JSON.stringify([ids, assignable]);
|
|
@@ -227,17 +387,7 @@ function render(snapshot) {
|
|
|
227
387
|
target.append(row);
|
|
228
388
|
}
|
|
229
389
|
});
|
|
230
|
-
|
|
231
|
-
update('permissions', snapshot.permissions, (target) => {
|
|
232
|
-
if (!snapshot.permissions.length) return empty(target, 'No approvals waiting.');
|
|
233
|
-
for (const permission of snapshot.permissions) {
|
|
234
|
-
const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
|
|
235
|
-
if (permission.terminalOnly) row.append(el('p', 'Review details and allow in the terminal with ahub tail. You can deny here.', 'meta'));
|
|
236
|
-
const actions = el('div', undefined, 'actions');
|
|
237
|
-
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')); }
|
|
238
|
-
row.append(actions); target.append(row);
|
|
239
|
-
}
|
|
240
|
-
});
|
|
390
|
+
renderApprovals(snapshot.permissions);
|
|
241
391
|
update('budget', snapshot.budget, (target) => {
|
|
242
392
|
if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
|
|
243
393
|
for (const [peer, budget] of Object.entries(snapshot.budget)) {
|
|
@@ -263,6 +413,7 @@ function render(snapshot) {
|
|
|
263
413
|
}
|
|
264
414
|
});
|
|
265
415
|
renderBench(snapshot.bench);
|
|
416
|
+
renderSettings(snapshot.settings);
|
|
266
417
|
const stream = $('stream');
|
|
267
418
|
if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
|
|
268
419
|
for (const entry of snapshot.events || []) {
|