@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +16 -8
  3. package/docs/agent-notes/adapters.md +10 -5
  4. package/docs/agent-notes/benchmarks.md +3 -0
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/daemon.md +8 -1
  7. package/docs/agent-notes/local-worker.md +3 -2
  8. package/docs/agent-notes/tasks.md +2 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +7 -3
  11. package/docs/operations.md +232 -26
  12. package/docs/quickstart.md +9 -4
  13. package/docs/security.md +15 -6
  14. package/docs/smoke.md +72 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
  16. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  17. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  18. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  19. package/docs/verified.json +25 -16
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +14 -3
  23. package/src/adapters/acp-quota.ts +13 -0
  24. package/src/adapters/acp.ts +95 -13
  25. package/src/adapters/codex-appserver.ts +43 -13
  26. package/src/cli/bench.ts +2 -1
  27. package/src/cli/console-state.ts +28 -22
  28. package/src/cli/console.ts +20 -10
  29. package/src/cli/help.ts +19 -14
  30. package/src/cli/identity.ts +2 -2
  31. package/src/cli/init.ts +1 -1
  32. package/src/cli/main.ts +272 -159
  33. package/src/cli/output.ts +363 -0
  34. package/src/cli/preview.ts +2 -1
  35. package/src/cli/reset.ts +3 -0
  36. package/src/cli/status-lines.ts +0 -28
  37. package/src/cli/terminal-recovery.ts +27 -1
  38. package/src/cli/upgrade-runtime.ts +24 -7
  39. package/src/cli/upgrade.ts +23 -5
  40. package/src/hub/board.ts +2 -0
  41. package/src/hub/budget.ts +39 -7
  42. package/src/hub/bus.ts +10 -11
  43. package/src/hub/child-process.ts +11 -0
  44. package/src/hub/conductor.ts +23 -11
  45. package/src/hub/config-trust.ts +16 -5
  46. package/src/hub/conflicts.ts +2 -0
  47. package/src/hub/context-window.ts +8 -1
  48. package/src/hub/crash.ts +2 -1
  49. package/src/hub/daemon.ts +338 -23
  50. package/src/hub/db-timeout.ts +3 -0
  51. package/src/hub/delivery-journal.ts +2 -1
  52. package/src/hub/envelope.ts +1 -1
  53. package/src/hub/events.ts +2 -0
  54. package/src/hub/execution-budget.ts +2 -0
  55. package/src/hub/facts.ts +5 -4
  56. package/src/hub/git.ts +32 -0
  57. package/src/hub/hub-tools.ts +1 -1
  58. package/src/hub/lifecycle.ts +10 -4
  59. package/src/hub/manager.ts +18 -5
  60. package/src/hub/peers.ts +5 -3
  61. package/src/hub/permission-mode.ts +14 -0
  62. package/src/hub/project.ts +2 -1
  63. package/src/hub/recovery-error.ts +31 -0
  64. package/src/hub/recovery-store.ts +6 -5
  65. package/src/hub/registry.ts +2 -1
  66. package/src/hub/routing.ts +114 -13
  67. package/src/hub/settings.ts +392 -0
  68. package/src/hub/snapshots.ts +4 -2
  69. package/src/hub/start-mode.ts +48 -0
  70. package/src/hub/tasks.ts +129 -31
  71. package/src/hub/terminal-open.ts +64 -0
  72. package/src/hub/ui.ts +25 -13
  73. package/src/local/deny.ts +29 -7
  74. package/src/local/sandbox.ts +44 -6
  75. package/src/memory/recall.ts +2 -2
  76. package/src/models/mlx.ts +2 -1
  77. package/src/pi/tool-receipts.ts +2 -1
  78. 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("PRAGMA busy_timeout = 100");
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,
@@ -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
- try { const result = await post('/action', payload); if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.'); return true; }
87
- catch (error) { if (!stopped && (!managerMode || captured.generation === viewGeneration)) notice(error.message, true); return false; }
88
- finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = !managerAction && managerMode && !snapshotValid; } }
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
- update('peers', peers, (target) => {
199
- if (!ids.length) return empty(target, 'No peers connected. Start an agent from your terminal.');
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 || []) {