@staix/agent-hub 0.12.22 → 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 +19 -0
- package/README.md +16 -8
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/local-worker.md +3 -2
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +7 -3
- package/docs/operations.md +232 -26
- package/docs/quickstart.md +9 -4
- package/docs/security.md +15 -6
- package/docs/smoke.md +72 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
- 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 +25 -16
- 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 +95 -13
- package/src/adapters/codex-appserver.ts +43 -13
- 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 +19 -14
- package/src/cli/identity.ts +2 -2
- package/src/cli/init.ts +1 -1
- package/src/cli/main.ts +272 -159
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- 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-runtime.ts +24 -7
- package/src/cli/upgrade.ts +23 -5
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +10 -11
- 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 +338 -23
- 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 +2 -0
- 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 +5 -3
- 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 +6 -5
- 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 +129 -31
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +44 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/tool-receipts.ts +2 -1
- package/src/ui/index.html +157 -10
package/src/models/mlx.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface ProcessSignature {
|
|
|
73
73
|
const DEFAULT_RUNTIME_DIR = join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
74
74
|
const DEFAULT_MODEL_PATH = join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
75
75
|
const OWNER_FILE = "owner.json";
|
|
76
|
+
const GENERATION_DB_BUSY_TIMEOUT_MS = 100;
|
|
76
77
|
const GENERATION_DB = "generation-slots.db";
|
|
77
78
|
|
|
78
79
|
const ownerPath = (runtimeDir: string) => join(runtimeDir, OWNER_FILE);
|
|
@@ -185,7 +186,7 @@ function generationDatabase(runtimeDir: string): Database {
|
|
|
185
186
|
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
186
187
|
const db = new Database(generationDbPath(runtimeDir), { create: true });
|
|
187
188
|
try {
|
|
188
|
-
db.run(
|
|
189
|
+
db.run(`PRAGMA busy_timeout = ${GENERATION_DB_BUSY_TIMEOUT_MS}`);
|
|
189
190
|
db.run(`CREATE TABLE IF NOT EXISTS generation_slots (
|
|
190
191
|
slot INTEGER PRIMARY KEY,
|
|
191
192
|
token TEXT NOT NULL,
|
package/src/pi/tool-receipts.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { chmodSync } from "node:fs";
|
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { PreEffectToolRefusal } from "../hub/tool-refusal.ts";
|
|
5
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
5
6
|
|
|
6
7
|
export const PI_TOOL_RESULT_CAP = 100_000;
|
|
7
8
|
/** Bound encoded text before wrapping it so escaping never cuts off trusted exit metadata. */
|
|
@@ -27,8 +28,8 @@ export class PiToolReceipts {
|
|
|
27
28
|
private readonly pending = new Map<string, Promise<string>>();
|
|
28
29
|
constructor(file: string) {
|
|
29
30
|
this.db = new Database(file, { create: true });
|
|
31
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
30
32
|
chmodSync(file, 0o600);
|
|
31
|
-
this.db.run("PRAGMA busy_timeout = 5000");
|
|
32
33
|
this.db.run(`CREATE TABLE IF NOT EXISTS pi_tool_receipts (
|
|
33
34
|
session TEXT NOT NULL, call_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
|
34
35
|
state TEXT NOT NULL, result TEXT, PRIMARY KEY(session, call_id))`);
|
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);
|
|
@@ -192,11 +221,127 @@ function renderApprovals(permissions) {
|
|
|
192
221
|
}
|
|
193
222
|
});
|
|
194
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
|
+
}
|
|
195
339
|
function render(snapshot) {
|
|
196
|
-
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 : [];
|
|
197
341
|
$('peer-count').textContent = ids.length + ' peers';
|
|
198
|
-
|
|
199
|
-
|
|
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); }
|
|
200
345
|
for (const [id, peer] of Object.entries(peers)) {
|
|
201
346
|
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
|
|
202
347
|
name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
|
|
@@ -214,6 +359,7 @@ function render(snapshot) {
|
|
|
214
359
|
const paused = peer.state === 'paused' || peer.paused;
|
|
215
360
|
row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
|
|
216
361
|
}
|
|
362
|
+
renderStarts(target, starts);
|
|
217
363
|
});
|
|
218
364
|
const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
|
|
219
365
|
const peerSignature = JSON.stringify([ids, assignable]);
|
|
@@ -267,6 +413,7 @@ function render(snapshot) {
|
|
|
267
413
|
}
|
|
268
414
|
});
|
|
269
415
|
renderBench(snapshot.bench);
|
|
416
|
+
renderSettings(snapshot.settings);
|
|
270
417
|
const stream = $('stream');
|
|
271
418
|
if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
|
|
272
419
|
for (const entry of snapshot.events || []) {
|