@crewhaus/hangar-ui 0.5.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.
Files changed (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * HM-189 — the ⌘K overlay.
3
+ *
4
+ * A text field, a ranked result list, and — kept visually and behaviourally
5
+ * separate — the ACTIONS the server proposed for the query. Navigation
6
+ * results are links: pressing Enter on one goes somewhere. Actions are not:
7
+ * an action needs a second, explicit confirm before the console calls the
8
+ * ordinary route, and the row shows the exact CLI command it will run first.
9
+ *
10
+ * That split is the point. An omnibox that can start a daemon on Enter is a
11
+ * loaded gun on the keyboard; one that can only ever propose, and shows its
12
+ * CLI twin while proposing, is a faster way to do the thing you were going
13
+ * to do anyway.
14
+ *
15
+ * The index itself lives server-side and is built lazily (nothing is
16
+ * indexed until the first query), so opening this overlay on a hundred-
17
+ * harness machine costs one request, not a scan.
18
+ */
19
+
20
+ import { api } from "./api.js";
21
+ import { clear, el, emptyState, toast } from "./dom.js";
22
+ import { ROUTES } from "./routes.js";
23
+
24
+ /** Debounce for keystroke → request. Short enough to feel live, long enough
25
+ * that a fast typist issues one request per word, not per letter. */
26
+ const DEBOUNCE_MS = 120;
27
+
28
+ /** Kind → the short label shown in the row's leading chip. */
29
+ const KIND_LABEL = {
30
+ harness: "harness",
31
+ group: "group",
32
+ tag: "tag",
33
+ session: "session",
34
+ wiki: "wiki",
35
+ fact: "facts",
36
+ dataset: "dataset",
37
+ grader: "grader",
38
+ "eval-run": "eval run",
39
+ incident: "incident",
40
+ approval: "approval",
41
+ action: "action",
42
+ };
43
+
44
+ /**
45
+ * Which api wrapper an action's `route` names. Actions only ever reference
46
+ * the process verbs, and this map is the allowlist that keeps it that way:
47
+ * a server proposing anything else finds no executor here and the row
48
+ * renders disabled with the reason.
49
+ */
50
+ const ACTION_EXECUTORS = {
51
+ procStart: (id) => api.procStart(id),
52
+ procStop: (id) => api.procStop(id),
53
+ procRestart: (id) => api.procRestart(id),
54
+ procDrain: (id) => api.procDrain(id),
55
+ };
56
+
57
+ /**
58
+ * Mount the overlay. Returns `{ close }`; the caller (app.js) owns when it
59
+ * opens, because ⌘K ownership is decided by the `keys.js` reducer.
60
+ */
61
+ export function openOmnibox(host, opts = {}) {
62
+ const onClose = opts.onClose ?? (() => {});
63
+ const backdrop = el("div", { class: "omni-backdrop", role: "presentation" });
64
+ const input = el("input", {
65
+ class: "omni-input",
66
+ type: "search",
67
+ placeholder: "Search harnesses, sessions, wiki, runs… or type “start <harness>”",
68
+ "aria-label": "search the fleet",
69
+ autocomplete: "off",
70
+ spellcheck: "false",
71
+ });
72
+ const listNode = el("div", { class: "omni-list", role: "listbox" });
73
+ const hint = el("div", { class: "omni-hint muted" }, [
74
+ el("span", { text: "Enter opens · actions ask first · Esc closes" }),
75
+ ]);
76
+ const panel = el(
77
+ "div",
78
+ { class: "omni-panel", role: "dialog", "aria-modal": "true", "aria-label": "omnibox" },
79
+ [input, listNode, hint],
80
+ );
81
+ backdrop.appendChild(panel);
82
+ host.appendChild(backdrop);
83
+
84
+ let rows = [];
85
+ let cursor = 0;
86
+ let seq = 0;
87
+ let timer = null;
88
+ let pendingConfirm = null;
89
+
90
+ const close = () => {
91
+ if (timer !== null) clearTimeout(timer);
92
+ backdrop.remove();
93
+ onClose();
94
+ };
95
+
96
+ backdrop.addEventListener("click", (event) => {
97
+ if (event.target === backdrop) close();
98
+ });
99
+
100
+ const draw = () => {
101
+ clear(listNode);
102
+ if (rows.length === 0) {
103
+ listNode.appendChild(
104
+ input.value.trim() === ""
105
+ ? el("div", { class: "empty" }, [
106
+ el("div", { class: "empty-msg", text: "Type to search the fleet" }),
107
+ el("div", {
108
+ class: "empty-verb muted",
109
+ text: "the index is built on demand — nothing is scanned until you ask",
110
+ }),
111
+ ])
112
+ : emptyState(`Nothing matches “${input.value.trim()}”`, "crewhaus harness list"),
113
+ );
114
+ return;
115
+ }
116
+ rows.forEach((row, i) => listNode.appendChild(rowNode(row, i)));
117
+ };
118
+
119
+ const rowNode = (row, index) => {
120
+ const selected = index === cursor;
121
+ const chip = el("span", { class: "omni-kind", text: KIND_LABEL[row.kind] ?? row.kind });
122
+ if (row.kind === "action") {
123
+ const confirming = pendingConfirm === row.id;
124
+ const node = el(
125
+ "div",
126
+ {
127
+ class: `omni-row omni-action${selected ? " selected" : ""}`,
128
+ role: "option",
129
+ "aria-selected": selected ? "true" : "false",
130
+ },
131
+ [
132
+ chip,
133
+ el("span", { class: "omni-title", text: row.title }),
134
+ el("code", { class: "omni-twin mono", text: row.cliTwin }),
135
+ confirming
136
+ ? el("span", { class: "omni-confirm" }, [
137
+ el("button", {
138
+ class: "btn btn-danger",
139
+ type: "button",
140
+ text: "Run it",
141
+ onClick: () => runAction(row),
142
+ }),
143
+ el("button", {
144
+ class: "btn btn-ghost",
145
+ type: "button",
146
+ text: "Cancel",
147
+ onClick: () => {
148
+ pendingConfirm = null;
149
+ draw();
150
+ },
151
+ }),
152
+ ])
153
+ : el("button", {
154
+ class: "btn",
155
+ type: "button",
156
+ text: "Confirm…",
157
+ onClick: () => {
158
+ pendingConfirm = row.id;
159
+ draw();
160
+ },
161
+ }),
162
+ ],
163
+ );
164
+ return node;
165
+ }
166
+ return el(
167
+ "a",
168
+ {
169
+ class: `omni-row${selected ? " selected" : ""}`,
170
+ role: "option",
171
+ "aria-selected": selected ? "true" : "false",
172
+ href: row.href,
173
+ onClick: () => close(),
174
+ },
175
+ [
176
+ chip,
177
+ el("span", { class: "omni-title", text: row.title }),
178
+ el("span", { class: "omni-sub muted", text: row.subtitle }),
179
+ ],
180
+ );
181
+ };
182
+
183
+ const runAction = async (row) => {
184
+ const executor = ACTION_EXECUTORS[row.route];
185
+ if (executor === undefined) {
186
+ toast(`This build cannot run “${row.route}” from the omnibox`, "error");
187
+ return;
188
+ }
189
+ pendingConfirm = null;
190
+ try {
191
+ const res = await executor(row.params?.id);
192
+ // The process routes answer a typed refusal as a BODY; surface it.
193
+ if (res && res.ok === false) {
194
+ toast(String(res.body?.message ?? res.body?.reason ?? "refused"), "error");
195
+ } else {
196
+ toast(`${row.title} — sent`, "info");
197
+ }
198
+ } catch (err) {
199
+ toast(err instanceof Error ? err.message : String(err), "error");
200
+ }
201
+ close();
202
+ };
203
+
204
+ const search = async () => {
205
+ const query = input.value.trim();
206
+ const mine = ++seq;
207
+ if (query === "") {
208
+ rows = [];
209
+ draw();
210
+ return;
211
+ }
212
+ let payload = null;
213
+ try {
214
+ payload = await api.search(query);
215
+ } catch (err) {
216
+ if (mine !== seq) return;
217
+ rows = [];
218
+ clear(listNode).appendChild(
219
+ emptyState(
220
+ `Search failed: ${err instanceof Error ? err.message : String(err)}`,
221
+ "crewhaus harness list",
222
+ ),
223
+ );
224
+ return;
225
+ }
226
+ if (mine !== seq) return; // a newer keystroke already answered
227
+ rows = omniRows(payload);
228
+ cursor = 0;
229
+ pendingConfirm = null;
230
+ draw();
231
+ };
232
+
233
+ input.addEventListener("input", () => {
234
+ if (timer !== null) clearTimeout(timer);
235
+ timer = setTimeout(search, DEBOUNCE_MS);
236
+ });
237
+
238
+ input.addEventListener("keydown", (event) => {
239
+ if (event.key === "ArrowDown" || (event.key === "n" && event.ctrlKey)) {
240
+ event.preventDefault();
241
+ cursor = rows.length === 0 ? 0 : Math.min(cursor + 1, rows.length - 1);
242
+ draw();
243
+ return;
244
+ }
245
+ if (event.key === "ArrowUp" || (event.key === "p" && event.ctrlKey)) {
246
+ event.preventDefault();
247
+ cursor = Math.max(cursor - 1, 0);
248
+ draw();
249
+ return;
250
+ }
251
+ if (event.key === "Enter") {
252
+ const row = rows[cursor];
253
+ if (row === undefined) return;
254
+ event.preventDefault();
255
+ // Enter NEVER runs an action: it asks. Navigation rows go.
256
+ if (row.kind === "action") {
257
+ pendingConfirm = row.id;
258
+ draw();
259
+ return;
260
+ }
261
+ window.location.hash = row.href.startsWith("#") ? row.href.slice(1) : row.href;
262
+ close();
263
+ }
264
+ });
265
+
266
+ input.focus();
267
+ draw();
268
+ return { close };
269
+ }
270
+
271
+ /**
272
+ * The server payload → the overlay's row list: navigation entries first,
273
+ * then actions. Pure, and exported so the ordering and the shape can be
274
+ * tested without a DOM.
275
+ */
276
+ export function omniRows(payload) {
277
+ const body = payload && typeof payload === "object" ? payload : {};
278
+ const entries = Array.isArray(body.entries) ? body.entries : [];
279
+ const actions = Array.isArray(body.actions) ? body.actions : [];
280
+ return [
281
+ ...entries.map((e) => ({
282
+ kind: typeof e.kind === "string" ? e.kind : "harness",
283
+ id: String(e.id ?? ""),
284
+ title: String(e.title ?? ""),
285
+ subtitle: String(e.subtitle ?? ""),
286
+ href: typeof e.href === "string" ? e.href : "#/",
287
+ })),
288
+ ...actions.map((a) => ({
289
+ kind: "action",
290
+ id: String(a.id ?? ""),
291
+ title: String(a.label ?? ""),
292
+ subtitle: "",
293
+ href: "#/",
294
+ route: String(a.route ?? ""),
295
+ params: a.params && typeof a.params === "object" ? a.params : {},
296
+ cliTwin: String(a.cliTwin ?? ""),
297
+ // Never trusted from the payload: an action is confirmed here, always.
298
+ confirm: true,
299
+ })),
300
+ ];
301
+ }
302
+
303
+ /** True when this build can execute the named action route. Exported so the
304
+ * row can render disabled-with-reason rather than failing on click. */
305
+ export function canRunAction(route) {
306
+ return Object.hasOwn(ACTION_EXECUTORS, String(route)) && Object.hasOwn(ROUTES, String(route));
307
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Hash router. `parseRoute` is pure (unit-tested under bun); `startRouter`
3
+ * is the only function that touches `window`, so importing this module
4
+ * outside a browser is safe.
5
+ *
6
+ * Route map (every screen deep-linkable):
7
+ * #/ → Library
8
+ * #/runs → Runs & daemons (fleet supervision)
9
+ * #/approvals → the approvals inbox
10
+ * #/review → the review queue
11
+ * #/activity → the activity digest
12
+ * #/h/<id> → harness detail, Overview tab
13
+ * #/h/<id>/spec → Spec
14
+ * #/h/<id>/runs → this harness's run ledger
15
+ * #/h/<id>/runs/<runId> → one run's console (live SSE feed)
16
+ * #/h/<id>/schedulers → the four-lane timeline
17
+ * #/h/<id>/sessions → Sessions list
18
+ * #/h/<id>/sessions/<sess> → one transcript
19
+ * #/h/<id>/evals → eval run history
20
+ * #/h/<id>/evals/<runId> → one run
21
+ * #/h/<id>/evals/<runId>/<sampleId> → one sample
22
+ * #/h/<id>/memory → memory fabric
23
+ * #/h/<id>/memory/wiki/<slug> → wiki article reader
24
+ * #/h/<id>/costs → costs
25
+ * #/h/<id>/deploy → deployment records
26
+ *
27
+ * M3 adds the detail tabs and two more fleet screens:
28
+ * #/health → the fleet health board (M4)
29
+ * #/settings → notification rules, read-only, plugins (M4)
30
+ * #/h/<id>/panes → sandboxed plugin panes (M4)
31
+ * #/credentials → the fleet credential matrix
32
+ * #/thredz → the global Thredz explorer
33
+ * #/feedback → the fleet feedback rollup
34
+ * #/h/<id>/{data,feedback,creds,channels,security,thredz,inspect,dev}
35
+ * Each M3 tab accepts trailing segments, captured verbatim as `rest` — the
36
+ * sub-screens inside them (a dataset, a store, a Thredz article) are the
37
+ * area implementer's to name, and the router should not have to be edited
38
+ * again for each one.
39
+ */
40
+
41
+ export const HARNESS_TABS = [
42
+ "overview",
43
+ "spec",
44
+ "runs",
45
+ "schedulers",
46
+ "sessions",
47
+ "evals",
48
+ "memory",
49
+ "data",
50
+ "feedback",
51
+ "costs",
52
+ "creds",
53
+ "channels",
54
+ "security",
55
+ "thredz",
56
+ "deploy",
57
+ "inspect",
58
+ "dev",
59
+ // M4 (HM-179): the sandboxed plugin panes this harness shows. Always on
60
+ // the strip — a harness with no applicable pane renders the honest empty
61
+ // state naming the install verb, which is more useful than a tab that
62
+ // appears only once a plugin exists.
63
+ "panes",
64
+ ];
65
+
66
+ /** The M3 tabs, whose trailing segments are captured generically as `rest`.
67
+ * Shape gating (which tabs a given target shows) is applied where the tab
68
+ * strip is drawn, through {@link tabAvailability} below — never here: a deep
69
+ * link to an off-matrix tab must still RESOLVE, because the honest empty
70
+ * state that tab renders is a real answer and a shared URL must not 404. */
71
+ export const M3_TABS = [
72
+ "data",
73
+ "feedback",
74
+ "creds",
75
+ "channels",
76
+ "security",
77
+ "thredz",
78
+ "inspect",
79
+ "dev",
80
+ ];
81
+
82
+ /** The global (fleet-wide) screens, in nav order. `#/` is the Library. */
83
+ export const GLOBAL_VIEWS = [
84
+ "runs",
85
+ "approvals",
86
+ "review",
87
+ "activity",
88
+ "credentials",
89
+ "feedback",
90
+ "thredz",
91
+ // M4: the fleet health board (HM-11) and Settings (HM-198's console half:
92
+ // notification rules, read-only mode, the plugin inventory).
93
+ "health",
94
+ "settings",
95
+ ];
96
+
97
+ /**
98
+ * The compile targets, in the order the plan's shape × panel matrix lists
99
+ * them. This is the strict union `@crewhaus/spec` validates against; a
100
+ * target that is NOT in this list is unknown to this build, and an unknown
101
+ * shape is gated at nothing — hiding a panel from a shape the console has
102
+ * never heard of would be a guess wearing the clothes of a rule.
103
+ */
104
+ export const SHAPE_TARGETS = [
105
+ "cli",
106
+ "workflow",
107
+ "channel",
108
+ "graph",
109
+ "managed",
110
+ "pipeline",
111
+ "crew",
112
+ "research",
113
+ "batch",
114
+ "voice",
115
+ "browser",
116
+ "eval",
117
+ "onchain",
118
+ "onchain-game",
119
+ ];
120
+
121
+ /**
122
+ * The shape × panel matrix, as data.
123
+ *
124
+ * Every target gets the universal set — Overview, Spec, Runs, Sessions,
125
+ * Evals, Datasets, Costs, Credentials, Security, Deployments, Inspect and
126
+ * Dev & MCP — because each of those reads something every harness has (a
127
+ * directory, a spec, a session store, a ledger). The five tabs below are
128
+ * per-shape ADDITIONS, and the rule for each is the same one the spec
129
+ * schema already enforces: **a tab is on the matrix for a target when that
130
+ * target's schema admits at least one of the blocks the tab configures.**
131
+ *
132
+ * That rule is deliberately narrower than "the store might hold something":
133
+ * a tab whose subject the target cannot declare has nothing to configure
134
+ * and nothing to wire, so linking to it buys the operator a screen whose
135
+ * only content is the sentence "not applicable". It is also deliberately
136
+ * NOT a hard gate — `parseRoute` still resolves the deep link and the
137
+ * screen still answers, so nothing an operator saved is unreachable.
138
+ */
139
+ export const SHAPE_GATED_TABS = {
140
+ channels: {
141
+ // `channels:`, `routing:` and `gateway:` are channel-only in the union.
142
+ shapes: ["channel"],
143
+ why: "a channels: block (with its routing and gateway) is only valid in a channel spec",
144
+ },
145
+ schedulers: {
146
+ // heartbeat: channel · schedule: channel/managed/batch · memory.dream:
147
+ // every shape with a memory block · janitor: the daemon shapes.
148
+ shapes: ["cli", "channel", "managed", "crew", "research", "batch", "voice"],
149
+ why: "no lane can arm here — this target's schema admits no heartbeat:, schedule: or memory.dream: block, and its bundle runs no janitor",
150
+ },
151
+ memory: {
152
+ // memory:/knowledge:/learning:/watchme:, or continuity: on its own.
153
+ shapes: [
154
+ "cli",
155
+ "workflow",
156
+ "channel",
157
+ "managed",
158
+ "crew",
159
+ "research",
160
+ "batch",
161
+ "voice",
162
+ "browser",
163
+ ],
164
+ why: "this target's schema admits neither a memory: nor a continuity: block, so there is no fabric to read",
165
+ },
166
+ feedback: {
167
+ shapes: ["cli", "channel", "managed"],
168
+ why: "this target's schema admits no feedback: block, so the ratings loop — reactions, distill sinks, advice — is not wired here",
169
+ },
170
+ thredz: {
171
+ shapes: ["cli", "channel", "managed", "crew", "research"],
172
+ why: "this target's schema admits no thredz: block, so there is no workspace for the manager to proxy",
173
+ },
174
+ };
175
+
176
+ /**
177
+ * Which tabs this target shows, in strip order.
178
+ *
179
+ * Returns one row per {@link HARNESS_TABS} entry: `{ tab, on, reason }`.
180
+ * `on:false` rows carry the sentence the strip renders instead of a link —
181
+ * a disabled tab that says why is information; a live link into an empty
182
+ * screen is a maze.
183
+ */
184
+ export function tabAvailability(target) {
185
+ const shape = typeof target === "string" ? target : "";
186
+ const known = SHAPE_TARGETS.includes(shape);
187
+ return HARNESS_TABS.map((tab) => {
188
+ const gate = Object.hasOwn(SHAPE_GATED_TABS, tab) ? SHAPE_GATED_TABS[tab] : null;
189
+ if (gate === null || !known || gate.shapes.includes(shape)) {
190
+ return { tab, on: true, reason: null };
191
+ }
192
+ return { tab, on: false, reason: `${gate.why} — this harness's target is ${shape}` };
193
+ });
194
+ }
195
+
196
+ function safeDecode(part) {
197
+ try {
198
+ return decodeURIComponent(part);
199
+ } catch {
200
+ return part;
201
+ }
202
+ }
203
+
204
+ /** Parse a location.hash string into a route object. Never throws. */
205
+ export function parseRoute(hash) {
206
+ const raw = typeof hash === "string" ? hash : "";
207
+ const h = raw.startsWith("#") ? raw.slice(1) : raw;
208
+ if (h === "" || h === "/") return { view: "library" };
209
+ const parts = h
210
+ .split("/")
211
+ .filter((p) => p !== "")
212
+ .map(safeDecode);
213
+ // Fleet-wide screens live at the root, harness screens under /h/<id>.
214
+ if (parts[0] !== "h") {
215
+ if (parts.length === 1 && GLOBAL_VIEWS.includes(parts[0])) return { view: parts[0] };
216
+ return { view: "notfound", hash: raw };
217
+ }
218
+ if (parts.length < 2) return { view: "notfound", hash: raw };
219
+ const id = parts[1];
220
+ const tab = parts[2] ?? "overview";
221
+ const rest = parts.slice(3);
222
+ switch (tab) {
223
+ case "overview":
224
+ case "spec":
225
+ case "costs":
226
+ case "schedulers":
227
+ case "deploy":
228
+ case "panes":
229
+ return { view: "harness", id, tab };
230
+ case "sessions":
231
+ return rest[0] !== undefined
232
+ ? { view: "harness", id, tab, sessionId: rest[0] }
233
+ : { view: "harness", id, tab };
234
+ case "runs":
235
+ return rest[0] !== undefined
236
+ ? { view: "harness", id, tab, runId: rest[0] }
237
+ : { view: "harness", id, tab };
238
+ case "evals": {
239
+ const route = { view: "harness", id, tab };
240
+ if (rest[0] !== undefined) route.runId = rest[0];
241
+ if (rest[1] !== undefined) route.sampleId = rest[1];
242
+ return route;
243
+ }
244
+ case "memory":
245
+ return rest[0] === "wiki" && rest[1] !== undefined
246
+ ? { view: "harness", id, tab, wikiSlug: rest[1] }
247
+ : { view: "harness", id, tab };
248
+ default:
249
+ // The M3 tabs take their trailing segments generically, so adding a
250
+ // sub-screen inside one is a view change, not a router change.
251
+ if (M3_TABS.includes(tab)) {
252
+ return rest.length > 0 ? { view: "harness", id, tab, rest } : { view: "harness", id, tab };
253
+ }
254
+ return { view: "notfound", hash: raw };
255
+ }
256
+ }
257
+
258
+ /** Build a deep link for a harness tab (+ optional trailing segments). */
259
+ export function hrefHarness(id, tab = "overview", ...rest) {
260
+ const segs = [`#/h/${encodeURIComponent(id)}`];
261
+ if (tab !== "overview" || rest.length > 0) segs.push(tab);
262
+ for (const part of rest) segs.push(encodeURIComponent(String(part)));
263
+ return segs.join("/");
264
+ }
265
+
266
+ /** The Library link. */
267
+ export function hrefLibrary() {
268
+ return "#/";
269
+ }
270
+
271
+ /** A fleet-wide screen's link (`#/runs`, `#/approvals`, …). */
272
+ export function hrefGlobal(view) {
273
+ return `#/${view}`;
274
+ }
275
+
276
+ /** Wire hashchange → handler and fire once for the current hash. */
277
+ export function startRouter(onChange) {
278
+ window.addEventListener("hashchange", () => onChange(parseRoute(window.location.hash)));
279
+ onChange(parseRoute(window.location.hash));
280
+ }