@staix/agent-hub 0.3.1 → 0.4.0

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.
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { basename } from "node:path";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import { readFileSync } from "node:fs";
3
4
  import type { PeerId } from "../hub/envelope.ts";
4
5
  import type { MemoryClient } from "./client.ts";
5
6
 
@@ -11,11 +12,33 @@ const HEADER =
11
12
  "Shared project memory (claude-mem): recent work by the agents in this project. Reference only, not a request. " +
12
13
  "Fetch details by id with your memory tools if you need them.";
13
14
 
14
- /** Project names from the outermost superproject down to this repo: claude-mem treats the last one as primary. */
15
+ /** Project names from the outermost superproject down to this repo. */
15
16
  export function projectChain(cwd: string): string[] {
16
17
  const git = (dir: string, arg: string) => spawnSync("git", ["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
18
+ const top = git(cwd, "--show-toplevel");
19
+ if (!top) return [basename(cwd)];
20
+
21
+ // claude-mem's native identity for a worktree is [parent, parent/worktree].
22
+ // The worktree's .git file points into <parent>/.git/worktrees/<name>; the
23
+ // ordinary superproject walk below remains unchanged for repos and submodules.
24
+ const gitFile = resolve(top, ".git");
25
+ try {
26
+ const match = readFileSync(gitFile, "utf8").trim().match(/^gitdir:\s*(.+)$/i);
27
+ if (match) {
28
+ const gitDir = resolve(top, match[1]!);
29
+ if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(gitDir)) {
30
+ const parentRoot = dirname(dirname(dirname(gitDir)));
31
+ const parent = projectChain(parentRoot);
32
+ const parentName = basename(parentRoot);
33
+ return [...parent, `${parentName}/${basename(top)}`];
34
+ }
35
+ }
36
+ } catch {
37
+ // A missing or unreadable .git file falls through to git's normal chain.
38
+ }
39
+
17
40
  const chain: string[] = [];
18
- for (let dir = git(cwd, "--show-toplevel"); dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
41
+ for (let dir = top; dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
19
42
  chain.unshift(basename(dir));
20
43
  }
21
44
  return chain.length ? chain : [basename(cwd)];
@@ -4,9 +4,11 @@ import { join } from "node:path";
4
4
  import type { Routing } from "../hub/routing.ts";
5
5
  import type { OmniRoute } from "../omniroute/client.ts";
6
6
  import { KEY_ENV, switchyardToml } from "./config.ts";
7
+ import { stopOwnedProcess } from "../hub/child-process.ts";
7
8
 
8
9
  class GatewayDown extends Error {}
9
10
 
11
+
10
12
  export interface SidecarOptions {
11
13
  routing: Routing;
12
14
  omni: OmniRoute;
@@ -25,6 +27,9 @@ export interface SidecarOptions {
25
27
  export class Sidecar {
26
28
  private proc: ChildProcess | undefined;
27
29
  private starting: Promise<string | undefined> | undefined;
30
+ private generation = 0;
31
+ private stopped = false;
32
+ private stopping: Promise<void> | undefined;
28
33
  private off = "";
29
34
  /** The gateway URL the running sidecar was generated with; calls through it go there whatever the client probes later. */
30
35
  upstream: string | undefined;
@@ -37,7 +42,7 @@ export class Sidecar {
37
42
 
38
43
  /** Base URL to send chat calls to, or undefined when the hub should call OmniRoute directly. */
39
44
  endpoint(): Promise<string | undefined> {
40
- if (this.off) return Promise.resolve(undefined);
45
+ if (this.off || this.stopped) return Promise.resolve(undefined);
41
46
  return (this.starting ??= this.start().catch((e: Error) => {
42
47
  // An unreachable gateway says nothing about Switchyard: this call goes direct, the next one tries the sidecar again.
43
48
  if (e instanceof GatewayDown) this.starting = undefined;
@@ -51,22 +56,34 @@ export class Sidecar {
51
56
  if (this.off) return;
52
57
  this.off = reason;
53
58
  this.opts.log(`switchyard: off, falling back to fixed_model on OmniRoute (${reason})`);
54
- this.stop();
59
+ void this.stop().catch((e: Error) => this.opts.log(`switchyard: shutdown incomplete (${e.message})`));
55
60
  }
56
61
 
57
- stop(): void {
58
- this.proc?.kill();
59
- this.proc = undefined;
62
+ async stop(): Promise<void> {
63
+ if (this.stopping) return this.stopping;
64
+ this.stopped = true;
65
+ ++this.generation;
66
+ const proc = this.proc;
67
+ // Preserve the historical synchronous file cleanup; the returned promise
68
+ // still represents the child process shutdown and must be awaited by the hub.
60
69
  rmSync(join(this.opts.stateDir, "switchyard.toml"), { force: true });
70
+ this.stopping = (async () => {
71
+ if (proc) await stopOwnedProcess(proc);
72
+ if (this.proc === proc) this.proc = undefined;
73
+ })();
74
+ return this.stopping;
61
75
  }
62
76
 
63
77
  private async start(): Promise<string | undefined> {
78
+ const generation = this.generation;
79
+ if (this.stopped) return undefined;
64
80
  const { routing, omni, stateDir, port, log } = this.opts;
65
81
  if (!Object.keys(routing.routes).length) throw new Error("routing.toml defines no routes");
66
82
  const bin = this.opts.bin ?? process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server";
67
83
  const baseUrl = await omni.base();
68
84
  const key = omni.apiKey();
69
85
  if (!baseUrl || !key) throw new GatewayDown("no OmniRoute endpoint or key");
86
+ if (this.stopped || generation !== this.generation) return undefined;
70
87
  this.upstream = baseUrl;
71
88
 
72
89
  const file = join(stateDir, "switchyard.toml");
@@ -79,6 +96,11 @@ export class Sidecar {
79
96
 
80
97
  // Switchyard's default host is 0.0.0.0; the hub binds loopback only.
81
98
  const proc = spawn(bin, ["--config", file, "--host", "127.0.0.1", "--port", String(port)], { env, stdio: ["ignore", "ignore", "pipe"] });
99
+ if (this.stopped || generation !== this.generation) {
100
+ proc.kill("SIGTERM");
101
+ await stopOwnedProcess(proc);
102
+ return undefined;
103
+ }
82
104
  this.proc = proc;
83
105
  let exited = false;
84
106
  // Switchyard logs every request at INFO on stderr; only problems belong in hub.log.
@@ -91,12 +113,24 @@ export class Sidecar {
91
113
  if (this.proc === proc) this.disable(`exited with code ${code}`);
92
114
  });
93
115
  for (let i = 0; i < 100 && !exited; i++) {
116
+ if (this.stopped || generation !== this.generation) {
117
+ await stopOwnedProcess(proc);
118
+ if (this.proc === proc) this.proc = undefined;
119
+ return undefined;
120
+ }
94
121
  if (await fetch(`http://127.0.0.1:${port}/health`).then((r) => r.ok, () => false)) {
122
+ if (this.stopped || generation !== this.generation) {
123
+ await stopOwnedProcess(proc);
124
+ if (this.proc === proc) this.proc = undefined;
125
+ return undefined;
126
+ }
95
127
  log(`switchyard: up on 127.0.0.1:${port}, routes ${Object.keys(routing.routes).join(", ")}`);
96
128
  return `http://127.0.0.1:${port}/v1`;
97
129
  }
98
130
  await Bun.sleep(100);
99
131
  }
132
+ await stopOwnedProcess(proc);
133
+ if (this.proc === proc) this.proc = undefined;
100
134
  throw new Error(exited ? "exited during startup" : "not healthy within 10 s");
101
135
  }
102
136
  }
package/src/ui/index.html CHANGED
@@ -5,19 +5,20 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>agent-hub | Local dashboard</title>
7
7
  <style>
8
- :root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--green:#245f45;--red:#a2392f}*{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,.meta{font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:#fff;border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:16px 18px;border-bottom:1px solid var(--line);gap:12px}.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}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:3px 7px;background:#edf2ee;color:var(--green);border-radius:4px}.badge.warn{color:#855417;background:#f9f0dc}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:#fff;color:var(--ink);border:1px solid #bbc4bb;border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:#f0f4ef}button.primary{background:var(--green);color:#fff;border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--green);outline-offset:3px}input,select,textarea{width:100%;border:1px solid #bbc4bb;border-radius:5px;padding:8px;background:#fff;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}.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 .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:#fff;overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:#dfb8b3}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}.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}}
8
+ :root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--green:#245f45;--red:#a2392f}*{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,.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:#fff;border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:16px 18px;border-bottom:1px solid var(--line);gap:12px}.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}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:3px 7px;background:#edf2ee;color:var(--green);border-radius:4px}.badge.warn{color:#855417;background:#f9f0dc}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:#fff;color:var(--ink);border:1px solid #bbc4bb;border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:#f0f4ef}button.primary{background:var(--green);color:#fff;border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--green);outline-offset:3px}input,select,textarea{width:100%;border:1px solid #bbc4bb;border-radius:5px;padding:8px;background:#fff;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}.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 .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:#fff;overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:#dfb8b3}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}.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}}
9
9
  </style>
10
10
  </head>
11
11
  <body>
12
- <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1></div><div id="connection" class="connection" role="status">Connecting...</div></header>
12
+ <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div id="connection" class="connection" role="status">Connecting...</div></header>
13
13
  <main>
14
14
  <p id="notice" class="notice" role="status" aria-live="polite"></p>
15
15
  <div class="stack">
16
16
  <section aria-labelledby="stream-title"><div class="section-head"><h2 id="stream-title">Live stream</h2><label><input id="follow" type="checkbox" checked> Follow latest</label></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div></section>
17
- <section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><span id="task-count" class="meta"></span></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" type="submit">Propose task</button></form></details></section>
18
- <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" type="submit">Send message</button></form></section>
17
+ <section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><span id="task-count" class="meta"></span></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
18
+ <section aria-labelledby="compose-title"><div class="section-head"><h2 id="compose-title">Console message</h2></div><form id="send" class="content"><div class="field"><label for="recipient">Recipient</label><select id="recipient" name="recipient"><option value="">All peers</option></select></div><div class="field"><label for="message">Message</label><textarea id="message" name="body" required maxlength="8000" placeholder="Send an instruction to the team..."></textarea></div><button class="primary" data-mutation="project" type="submit">Send message</button></form></section>
19
19
  </div>
20
20
  <div class="stack">
21
+ <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>
21
22
  <section aria-labelledby="peers-title"><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"></div></section>
22
23
  <section aria-labelledby="permissions-title"><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
23
24
  <section aria-labelledby="budget-title"><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div></section>
@@ -30,11 +31,15 @@ let ticket = location.hash.slice(1);
30
31
  history.replaceState(null, '', location.pathname);
31
32
  const $ = (id) => document.getElementById(id);
32
33
  const el = (tag, text, cls) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; };
33
- let stopped = false, cursor = 0, timer, eventCount = 0;
34
+ let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
35
+ let managerMode = false, projectList = [], selectedProjectId = '', selectedInstanceId = '', snapshotValid = false, viewGeneration = 0, projectRequest = 0;
36
+ const drafts = new Map();
37
+ const pendingButtons = new WeakSet();
38
+ let draftChoicesPending = false;
34
39
  const signatures = new Map();
35
40
  const time = (value) => value ? new Date(value).toLocaleString() : 'unknown';
36
41
  function notice(text, error = false) { $('notice').textContent = text; $('notice').classList.toggle('error', error); }
37
- function expire() { stopped = true; clearTimeout(timer); $('connection').textContent = 'Session closed'; notice('Session missing or expired. Run ahub ui in your terminal to reopen the dashboard.', true); document.querySelectorAll('button').forEach((button) => { button.disabled = true; }); }
42
+ function expire() { stopped = true; clearTimeout(timer); clearTimeout(projectsTimer); $('connection').textContent = 'Session closed'; notice('Session missing or expired. Run ahub ui in your terminal to reopen the dashboard.', true); document.querySelectorAll('button').forEach((button) => { button.disabled = true; }); }
38
43
  async function post(path, body) {
39
44
  const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
40
45
  if (response.status === 401) { expire(); throw new Error('Run ahub ui to start a new session.'); }
@@ -42,16 +47,86 @@ async function post(path, body) {
42
47
  if (!response.ok || result.ok === false) throw new Error(result.error || 'Request failed (' + response.status + ').');
43
48
  return result;
44
49
  }
45
- async function action(payload, button) {
50
+ function canMutate() { return !managerMode || snapshotValid; }
51
+ function context() { return { projectId: selectedProjectId, instanceId: selectedInstanceId, generation: viewGeneration }; }
52
+ async function action(payload, button, managerAction = false) {
46
53
  if (stopped) return false;
47
- if (button) button.disabled = true;
48
- try { const result = await post('/action', payload); notice(result.text || 'Action accepted.'); return true; }
49
- catch (error) { if (!stopped) notice(error.message, true); return false; }
50
- finally { if (button && !stopped) button.disabled = false; }
54
+ if (!managerAction && !canMutate()) { notice('Wait for a current project snapshot before changing it.', true); return false; }
55
+ const captured = context();
56
+ if (managerMode && !managerAction) payload = {...payload, projectId: captured.projectId, instanceId: captured.instanceId};
57
+ if (button) { pendingButtons.add(button); button.disabled = true; }
58
+ try { const result = await post('/action', payload); if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.'); return true; }
59
+ catch (error) { if (!stopped && (!managerMode || captured.generation === viewGeneration)) notice(error.message, true); return false; }
60
+ finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = !managerAction && managerMode && !snapshotValid; } }
51
61
  }
52
- function button(text, payload, cls) { const node = el('button', text, cls); node.type = 'button'; node.addEventListener('click', () => void action(payload, node)); return node; }
62
+ 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; }
53
63
  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); }
54
64
  function empty(target, text) { target.append(el('p', text, 'empty')); }
65
+ function count(value) { return Array.isArray(value) ? value.length : value && typeof value === 'object' ? Object.keys(value).length : value; }
66
+ function taskCount(value) { return Array.isArray(value) ? value.length : value && typeof value === 'object' ? Object.values(value).reduce((total, item) => total + (typeof item === 'number' ? item : 0), 0) : value; }
67
+ function saveDraft() {
68
+ if (!managerMode || !selectedProjectId) return;
69
+ const previous = drafts.get(selectedProjectId) || {};
70
+ drafts.set(selectedProjectId, {message:$('message').value, recipient:draftChoicesPending ? previous.recipient || '' : $('recipient').value,
71
+ title:$('title').value, detail:$('detail').value, class:$('class').value, owner:draftChoicesPending ? previous.owner || '' : $('owner').value});
72
+ }
73
+ function restoreDraft(choices = false) {
74
+ const draft = drafts.get(selectedProjectId) || {};
75
+ if (choices) {
76
+ for (const id of ['recipient','owner']) $(id).value = Array.from($(id).options).some((o) => o.value === draft[id]) ? draft[id] : '';
77
+ } else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
78
+ }
79
+ function syncMutationControls() { document.querySelectorAll('button[data-mutation="project"]').forEach((b) => { b.disabled = pendingButtons.has(b) || (managerMode && !snapshotValid); }); }
80
+ function resetView(id, instance) {
81
+ saveDraft();
82
+ selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
83
+ signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
84
+ for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
85
+ $('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
86
+ notice('');
87
+ draftChoicesPending = managerMode && Boolean(selectedProjectId);
88
+ peerOptions($('owner'), [], 'Automatic'); peerOptions($('recipient'), [], 'All peers');
89
+ restoreDraft(); syncMutationControls();
90
+ }
91
+ function projectName(root, id) { const value = String(root || id); return value.split('/').filter(Boolean).pop() || value; }
92
+ function renderProjects() {
93
+ $('project-count').textContent = managerMode ? projectList.length + ' registered' : '';
94
+ const selected = projectList.find((project) => project && project.id === selectedProjectId);
95
+ $('project-context').textContent = managerMode ? (selected ? String(selected.root || selected.id) : 'No project selected') : '';
96
+ update('projects', [projectList, selectedProjectId], (target) => {
97
+ if (!managerMode) return empty(target, 'This is a project-local dashboard.');
98
+ if (!projectList.length) return empty(target, 'No registered projects.');
99
+ for (const project of projectList) {
100
+ const id = typeof project.id === 'string' ? project.id : '', state = String(project.state || 'unavailable');
101
+ const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
102
+ head.append(el('span', projectName(project.root, id), 'label'), el('span', state, 'badge' + (['unavailable','incompatible','missing'].includes(state) ? ' warn' : ''))); row.append(head); row.append(el('p', String(project.root || id), 'meta'));
103
+ const counts = []; if (project.peers !== undefined) counts.push(String(count(project.peers)) + ' peers'); if (project.tasks !== undefined) counts.push(String(taskCount(project.tasks)) + ' tasks');
104
+ if (counts.length) row.append(el('p', counts.join(' / '), 'meta')); if (project.warning || project.error) row.append(el('p', project.warning || project.error, 'meta'));
105
+ const actions = el('div', undefined, 'actions');
106
+ if (state === 'running' && id === selectedProjectId) {
107
+ actions.append(el('span', 'Selected', 'badge'));
108
+ const stop = el('button', 'Stop', 'danger'); stop.type = 'button'; stop.addEventListener('click', () => { if (confirm('Stop ' + String(project.root || id) + '?')) void action({action:'stop', projectId:id, instanceId:typeof project.instanceId === 'string' ? project.instanceId : ''}, stop, true); }); actions.append(stop);
109
+ } else if (state === 'running') { const use = el('button', 'Use'); use.type = 'button'; use.addEventListener('click', () => { resetView(id, typeof project.instanceId === 'string' ? project.instanceId : ''); renderProjects(); }); actions.append(use); }
110
+ else if (['stopped','missing'].includes(state)) { const start = el('button', 'Start', 'primary'); start.type = 'button'; start.addEventListener('click', async () => { resetView(id, ''); renderProjects(); if (await action({action:'start', projectId:id}, start, true)) await refreshProjects(); }); actions.append(start); }
111
+ row.append(actions); target.append(row);
112
+ }
113
+ });
114
+ }
115
+ async function refreshProjects() {
116
+ if (stopped) return;
117
+ const request = ++projectRequest;
118
+ try {
119
+ const result = await post('/projects', {});
120
+ if (request !== projectRequest) return;
121
+ if (result.mode !== 'all') { managerMode = false; if (typeof result.selectedProjectId === 'string') selectedProjectId = result.selectedProjectId; renderProjects(); return; }
122
+ managerMode = true; projectList = Array.isArray(result.projects) ? result.projects : [];
123
+ if (!selectedProjectId && typeof result.selectedProjectId === 'string') selectedProjectId = result.selectedProjectId;
124
+ const selected = projectList.find((p) => p && p.id === selectedProjectId), nextInstance = selected && typeof selected.instanceId === 'string' ? selected.instanceId : '';
125
+ if (!selected || selected.state !== 'running' || nextInstance !== selectedInstanceId) resetView(selected?.id || '', nextInstance);
126
+ renderProjects();
127
+ } catch (error) { if (!stopped) $('connection').textContent = 'Projects unavailable: ' + error.message; }
128
+ if (!stopped) projectsTimer = setTimeout(refreshProjects, 3000);
129
+ }
55
130
  function peerOptions(select, ids, first) { const selected = select.value; select.replaceChildren(); const initial = el('option', first); initial.value = ''; select.append(initial); for (const id of ids) { const option = el('option', id); option.value = id; select.append(option); } if (ids.includes(selected)) select.value = selected; }
56
131
  function render(snapshot) {
57
132
  const peers = snapshot.status.peers || {}, ids = Object.keys(peers);
@@ -68,7 +143,7 @@ function render(snapshot) {
68
143
  }
69
144
  });
70
145
  const peerSignature = JSON.stringify(ids);
71
- if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), ids, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); }
146
+ if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), ids, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
72
147
  $('task-count').textContent = snapshot.tasks.length + ' tasks';
73
148
  update('tasks', [snapshot.tasks, ids], (target) => {
74
149
  if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
@@ -79,7 +154,7 @@ function render(snapshot) {
79
154
  if (task.detail) row.append(el('p', task.detail, 'body'));
80
155
  if (task.state !== 'approved' && ids.length) {
81
156
  const actions = el('div', undefined, 'actions'), select = el('select'); select.setAttribute('aria-label', 'Assign task ' + task.id); peerOptions(select, ids, 'Select peer'); if (ids.includes(task.owner)) select.value = task.owner;
82
- const assign = el('button', 'Assign'); assign.type = 'button'; assign.addEventListener('click', () => { if (select.value) void action({action:'assign', id:task.id, peer:select.value}, assign); else notice('Select a peer before assigning this task.', true); });
157
+ const assign = el('button', 'Assign'); assign.type = 'button'; assign.dataset.mutation = 'project'; assign.disabled = managerMode && !snapshotValid; assign.addEventListener('click', () => { if (select.value) void action({action:'assign', id:task.id, peer:select.value}, assign); else notice('Select a peer before assigning this task.', true); });
83
158
  actions.append(select, assign); row.append(actions);
84
159
  }
85
160
  target.append(row);
@@ -107,6 +182,7 @@ function render(snapshot) {
107
182
  }
108
183
  });
109
184
  const stream = $('stream');
185
+ if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
110
186
  for (const entry of snapshot.events || []) {
111
187
  if (entry.seq <= cursor) continue;
112
188
  if (!eventCount) stream.replaceChildren();
@@ -117,16 +193,25 @@ function render(snapshot) {
117
193
  }
118
194
  cursor = Math.max(cursor, snapshot.cursor || 0);
119
195
  if ($('follow').checked) stream.scrollTop = stream.scrollHeight;
196
+ syncMutationControls();
120
197
  }
121
198
  async function poll() {
122
199
  if (stopped) return;
123
- try { render(await post('/snapshot', {after:cursor})); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString(); }
200
+ const captured = context();
201
+ if (managerMode && (!captured.projectId || !captured.instanceId)) { if (!stopped) timer = setTimeout(poll, 1000); return; }
202
+ try {
203
+ const input = {after:cursor}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
204
+ const result = await post('/snapshot', input);
205
+ if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
206
+ snapshotValid = true; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
207
+ }
208
+ }
124
209
  catch (error) { if (!stopped) $('connection').textContent = 'Disconnected / retrying: ' + error.message; }
125
210
  if (!stopped) timer = setTimeout(poll, 1000);
126
211
  }
127
- $('send').addEventListener('submit', async (event) => { event.preventDefault(); const body = $('message').value.trim(); if (!body) return; const to = $('recipient').value, payload = {action:'send', body}; if (to) payload.to = [to]; if (await action(payload, event.submitter)) $('message').value = ''; });
128
- $('propose').addEventListener('submit', async (event) => { event.preventDefault(); const title = $('title').value.trim(); if (!title) return; const payload = {action:'propose', title, class:$('class').value}; if ($('detail').value.trim()) payload.detail = $('detail').value.trim(); if ($('owner').value) payload.owner = $('owner').value; if (await action(payload, event.submitter)) { $('title').value = ''; $('detail').value = ''; } });
129
- (async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; await poll(); } catch (error) { ticket = ''; if (!stopped) { stopped = true; notice('Could not start session: ' + error.message + ' Run ahub ui to reopen.', true); $('connection').textContent = 'Session unavailable'; } } })();
212
+ $('send').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, body = $('message').value.trim(); if (!body) return; const to = $('recipient').value, payload = {action:'send', body}; if (to) payload.to = [to]; if (await action(payload, event.submitter) && generation === viewGeneration) $('message').value = ''; });
213
+ $('propose').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, title = $('title').value.trim(); if (!title) return; const payload = {action:'propose', title, class:$('class').value}; if ($('detail').value.trim()) payload.detail = $('detail').value.trim(); if ($('owner').value) payload.owner = $('owner').value; if (await action(payload, event.submitter) && generation === viewGeneration) { $('title').value = ''; $('detail').value = ''; } });
214
+ (async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; await refreshProjects(); await poll(); } catch (error) { ticket = ''; if (!stopped) { stopped = true; notice('Could not start session: ' + error.message + ' Run ahub ui to reopen.', true); $('connection').textContent = 'Session unavailable'; } } })();
130
215
  </script>
131
216
  </body>
132
217
  </html>