@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,450 @@
1
+ /**
2
+ * Dev & MCP — the two remaining supervised run classes: the `serve --mcp` /
3
+ * `expose:` projection, and the `crewhaus dev` watch-recompile-relaunch
4
+ * loop.
5
+ *
6
+ * Both are PROCESSES, not panels: they go through the manager's own process
7
+ * layer and get a row in the harness's run ledger, so the next manager boot
8
+ * still sees them in history. Their live output is the EXISTING run console —
9
+ * these screens hand off a `runId` to `#/h/<id>/runs/<runId>` rather than
10
+ * opening a second stream.
11
+ *
12
+ * The reason dev mode is driven from here at all: `crewhaus dev` normally
13
+ * compiles into a temp directory and runs with that as the cwd, so sessions,
14
+ * memory and feedback land somewhere that disappears. The manager owns the
15
+ * spawn and sets the cwd to the harness dir, which keeps state in the
16
+ * harness's own `.crewhaus/` across every recompile. The card says which
17
+ * state roots are actually anchored rather than claiming a blanket fix.
18
+ *
19
+ * THREE PROJECTION ANSWERS, NEVER CONFLATED. A `cli` shape projects through a
20
+ * separate `crewhaus serve --mcp` process the manager can start; a
21
+ * channel/managed daemon self-exposes from its own compiled bundle, so the
22
+ * honest control there is the daemon's Start, not a second one; every other
23
+ * shape does not project at all. A Start button on the second or third case
24
+ * would be a dead button, so it is not offered — the reason is.
25
+ */
26
+
27
+ import { api } from "../api.js";
28
+ import { asOf, clear, collapsible, dot, el, emptyState, skeleton, toast } from "../dom.js";
29
+ import { hrefHarness } from "../router.js";
30
+ import { fmtRelativeTime } from "../util.js";
31
+ import { cliTwin, gatedBtn, runAction } from "./control.js";
32
+
33
+ export async function renderRuntime(root, ctx) {
34
+ clear(root).appendChild(skeleton(6));
35
+ const [mcp, dev] = await Promise.all([api.mcpServers({ id: ctx.id }), api.dev({ id: ctx.id })]);
36
+ clear(root);
37
+ const reload = () => renderRuntime(root, ctx);
38
+ root.appendChild(mcpCard(ctx, mcp, reload));
39
+ root.appendChild(devCard(ctx, dev, reload));
40
+ }
41
+
42
+ function errorCard(title, res) {
43
+ const message =
44
+ typeof res?.body?.error === "string" ? res.body.error : `HTTP ${res ? res.status : "?"}`;
45
+ return el("section", { class: "card error-card" }, [
46
+ el("h3", { class: "card-title" }, [dot("bad", title)]),
47
+ el("p", { class: "muted", text: message }),
48
+ ]);
49
+ }
50
+
51
+ /** A write whose REFUSAL is a payload: `{ok:false, code, reason}`. Reporting
52
+ * it is the whole point — a refusal is information, not an error toast. */
53
+ function reportWrite(res, label, reload) {
54
+ if (!res) return;
55
+ if (!res.ok) {
56
+ toast(`${label} failed: ${res.body?.error ?? `HTTP ${res.status}`}`);
57
+ return;
58
+ }
59
+ const body = res.body ?? {};
60
+ if (body.ok === false) {
61
+ toast(String(body.reason ?? `${label} was refused`));
62
+ } else if (body.started === true) {
63
+ toast(`${label}: started — watch it in the run console`, "info");
64
+ } else if (body.stopped === false) {
65
+ // "Nothing was signalled" must never read as "stopped".
66
+ toast(String(body.message ?? `${label} signalled nothing`));
67
+ } else if (body.stopped === true) {
68
+ toast(String(body.message ?? `${label}: stopped`), "info");
69
+ }
70
+ void reload();
71
+ }
72
+
73
+ /** The runs table both cards share; each row links into the ONE run feed. */
74
+ function runsTable(ctx, runs) {
75
+ const nowMs = Date.now();
76
+ const tbody = el("tbody");
77
+ for (const run of runs) {
78
+ const runId = typeof run.runId === "string" ? run.runId : null;
79
+ tbody.appendChild(
80
+ el("tr", null, [
81
+ el("td", null, [
82
+ runId === null
83
+ ? el("span", { class: "mono muted", text: String(run.jobId ?? "—") })
84
+ : el("a", {
85
+ class: "mono",
86
+ href: hrefHarness(ctx.id, "runs", runId),
87
+ text: runId,
88
+ }),
89
+ ]),
90
+ el("td", null, [stateDot(String(run.state ?? "unknown"))]),
91
+ el("td", { class: "mono", text: (run.argv ?? []).join(" ") }),
92
+ el("td", {
93
+ text:
94
+ typeof run.startedAt === "string"
95
+ ? fmtRelativeTime(run.startedAt, nowMs)
96
+ : `queued ${fmtRelativeTime(String(run.enqueuedAt ?? ""), nowMs)}`,
97
+ }),
98
+ el("td", {
99
+ class: "num",
100
+ text: typeof run.exitCode === "number" ? String(run.exitCode) : "—",
101
+ }),
102
+ el("td", { class: "muted", text: typeof run.error === "string" ? run.error : "" }),
103
+ ]),
104
+ );
105
+ }
106
+ return el("div", { class: "table-scroll" }, [
107
+ el("table", { class: "fleet" }, [
108
+ el(
109
+ "thead",
110
+ null,
111
+ el(
112
+ "tr",
113
+ null,
114
+ ["Run", "State", "Command", "When", "Exit", "Note"].map((h) => el("th", { text: h })),
115
+ ),
116
+ ),
117
+ tbody,
118
+ ]),
119
+ ]);
120
+ }
121
+
122
+ /** Traffic light + text, always paired. */
123
+ function stateDot(state) {
124
+ if (state === "running") return dot("ok", "running");
125
+ if (state === "pending") return dot("warn", "queued");
126
+ if (state === "done") return dot("ok", "done");
127
+ if (state === "failed") return dot("bad", "failed");
128
+ if (state === "interrupted") return dot("warn", "interrupted (manager restarted)");
129
+ if (state === "cancelled") return dot("off", "cancelled");
130
+ return dot("unknown", state);
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // The MCP projection
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function mcpCard(ctx, res, reload) {
138
+ if (!res.ok) return errorCard("The MCP projection could not be read", res);
139
+ const view = res.body ?? {};
140
+ const projection = String(view.projection ?? "none");
141
+ const running = view.running === true;
142
+ const health = view.health && typeof view.health === "object" ? view.health : {};
143
+ const supervision =
144
+ view.supervision && typeof view.supervision === "object" ? view.supervision : {};
145
+
146
+ const kv = (k, v) =>
147
+ el("div", { class: "kv" }, [
148
+ el("span", { class: "kv-k", text: k }),
149
+ typeof v === "string"
150
+ ? el("span", { class: "kv-v", text: v })
151
+ : el("span", { class: "kv-v" }, [v]),
152
+ ]);
153
+
154
+ const facts = el("div", { class: "proc-facts" }, [
155
+ kv("shape", String(view.target ?? "—")),
156
+ kv(
157
+ "projection",
158
+ projection === "serve"
159
+ ? dot("ok", "a separate `serve --mcp` process")
160
+ : projection === "self"
161
+ ? dot("unknown", "the daemon exposes itself")
162
+ : dot("off", "this shape does not project"),
163
+ ),
164
+ kv("state", running ? dot("ok", "running") : dot("off", "not running")),
165
+ kv("transport", String(view.transport ?? "—")),
166
+ kv("port", typeof view.port === "number" ? String(view.port) : "—"),
167
+ kv(
168
+ "listening",
169
+ health.checked === true
170
+ ? el("span", null, [
171
+ health.listening === true
172
+ ? dot("ok", "something is bound on loopback")
173
+ : dot("bad", "nothing is bound on that port"),
174
+ typeof health.at === "string" ? asOf(health.at) : null,
175
+ ])
176
+ : dot("unknown", "not probed"),
177
+ ),
178
+ ]);
179
+ if (typeof health.note === "string") {
180
+ facts.appendChild(el("p", { class: "muted reason", text: health.note }));
181
+ }
182
+
183
+ const actions = el("div", { class: "proc-actions" });
184
+ const startGate =
185
+ projection === "serve"
186
+ ? { enabled: !running, reason: running ? "a projection is already running" : null }
187
+ : { enabled: false, reason: String(view.projectionNote ?? "this shape does not project") };
188
+ const transportPick = el("select", { class: "search", "aria-label": "transport" });
189
+ for (const option of ["stdio", "http"]) {
190
+ const node = el("option", { value: option, text: option === "http" ? "HTTP + SSE" : "stdio" });
191
+ if (String(view.transport ?? "stdio") === option) node.selected = true;
192
+ transportPick.appendChild(node);
193
+ }
194
+ const startBtn = gatedBtn(
195
+ "Start projection",
196
+ startGate,
197
+ () => {
198
+ runAction(
199
+ startBtn,
200
+ "Start projection",
201
+ () => api.mcpServerStart({ id: ctx.id }, { transport: transportPick.value }),
202
+ (r) => reportWrite(r, "Start projection", reload),
203
+ );
204
+ },
205
+ "btn btn-primary",
206
+ );
207
+ const stopGate = {
208
+ enabled: running || supervision.stoppable === true,
209
+ reason: supervision.reason ?? "nothing to stop",
210
+ };
211
+ const stopBtn = gatedBtn("Stop", stopGate, () => {
212
+ runAction(
213
+ stopBtn,
214
+ "Stop",
215
+ () => api.mcpServerStop({ id: ctx.id }, {}),
216
+ (r) => reportWrite(r, "Stop", reload),
217
+ );
218
+ });
219
+ actions.appendChild(transportPick);
220
+ actions.appendChild(startBtn);
221
+ actions.appendChild(stopBtn);
222
+
223
+ const reasons = el("div", { class: "proc-reasons" });
224
+ if (!startGate.enabled && startGate.reason) {
225
+ reasons.appendChild(
226
+ el("div", { class: "muted gated-why", text: `start: ${startGate.reason}` }),
227
+ );
228
+ }
229
+ if (supervision.stoppable !== true && typeof supervision.reason === "string") {
230
+ reasons.appendChild(
231
+ el("div", { class: "muted gated-why", text: `stop: ${supervision.reason}` }),
232
+ );
233
+ }
234
+
235
+ const runs = Array.isArray(view.runs) ? view.runs : [];
236
+ const card = el("section", { class: "card ov-wide" }, [
237
+ el("h3", { class: "card-title" }, [
238
+ el("span", { text: "MCP projection" }),
239
+ running ? dot("ok", "running") : dot("off", "idle"),
240
+ ]),
241
+ el("p", { class: "muted", text: String(view.projectionNote ?? "") }),
242
+ facts,
243
+ el("div", { class: "proc-bar" }, [actions, reasons]),
244
+ cliTwin(
245
+ typeof view.cliTwin === "string" ? view.cliTwin : "",
246
+ "no crewhaus.yaml at the harness root — there is no command to run",
247
+ ),
248
+ ]);
249
+
250
+ if (runs.length === 0) {
251
+ card.appendChild(
252
+ emptyState(
253
+ typeof view.note === "string" && view.note !== ""
254
+ ? view.note
255
+ : "This harness has never projected an MCP server",
256
+ typeof view.verb === "string" ? view.verb : null,
257
+ ),
258
+ );
259
+ } else {
260
+ card.appendChild(runsTable(ctx, runs));
261
+ }
262
+ const ledger = Array.isArray(view.ledger) ? view.ledger : [];
263
+ if (ledger.length > 0) {
264
+ card.appendChild(
265
+ collapsible(
266
+ [el("span", { class: "muted", text: `run-ledger rows — ${ledger.length}` })],
267
+ [
268
+ el(
269
+ "div",
270
+ { class: "check-list" },
271
+ ledger.map((row) =>
272
+ el("div", { class: "check" }, [
273
+ el("span", { class: "mono", text: String(row.runId ?? "") }),
274
+ el("span", { class: "muted", text: (row.argv ?? []).join(" ") }),
275
+ ]),
276
+ ),
277
+ ),
278
+ ],
279
+ ),
280
+ );
281
+ }
282
+ return card;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Dev mode
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function devCard(ctx, res, reload) {
290
+ if (!res.ok) return errorCard("Dev mode could not be read", res);
291
+ const view = res.body ?? {};
292
+ const running = view.running === true;
293
+ const blocked = view.blocked === true;
294
+ const supervision =
295
+ view.supervision && typeof view.supervision === "object" ? view.supervision : {};
296
+ const lastCompile =
297
+ view.lastCompile && typeof view.lastCompile === "object" ? view.lastCompile : null;
298
+ const roots = Array.isArray(view.stateRoots) ? view.stateRoots : [];
299
+
300
+ const kv = (k, v) =>
301
+ el("div", { class: "kv" }, [
302
+ el("span", { class: "kv-k", text: k }),
303
+ typeof v === "string"
304
+ ? el("span", { class: "kv-v", text: v })
305
+ : el("span", { class: "kv-v" }, [v]),
306
+ ]);
307
+
308
+ const facts = el("div", { class: "proc-facts" }, [
309
+ kv(
310
+ "state",
311
+ running ? dot("ok", `running (${String(view.mode ?? "watch")})`) : dot("off", "not running"),
312
+ ),
313
+ kv(
314
+ "run",
315
+ typeof view.runId === "string"
316
+ ? el("a", {
317
+ class: "mono",
318
+ href: hrefHarness(ctx.id, "runs", view.runId),
319
+ text: view.runId,
320
+ })
321
+ : "—",
322
+ ),
323
+ kv("watching", (Array.isArray(view.watching) ? view.watching : []).join(", ") || "—"),
324
+ kv("cwd", String(view.cwd ?? "—")),
325
+ kv(
326
+ "last compile",
327
+ lastCompile === null
328
+ ? dot("unknown", "nothing compiled here yet")
329
+ : el("span", null, [
330
+ lastCompile.state === "done"
331
+ ? dot("ok", "clean")
332
+ : lastCompile.state === "failed"
333
+ ? dot("bad", `failed (exit ${lastCompile.exitCode ?? "?"})`)
334
+ : dot("warn", String(lastCompile.state ?? "unknown")),
335
+ typeof lastCompile.at === "string" ? asOf(lastCompile.at) : null,
336
+ ]),
337
+ ),
338
+ ]);
339
+ facts.appendChild(el("p", { class: "muted reason", text: String(view.cwdNote ?? "") }));
340
+
341
+ // Which parts of the state tree the manager has actually anchored — the
342
+ // honest version of "dev's temp-cwd trap is fixed".
343
+ const rootList = el("div", { class: "check-list" });
344
+ for (const root of roots) {
345
+ rootList.appendChild(
346
+ el("div", { class: "check" }, [
347
+ root.anchored === true
348
+ ? dot("ok", `${String(root.name)} → ${String(root.value)}`)
349
+ : dot("unknown", `${String(root.name)} — not pinned (follows the child's cwd)`),
350
+ ]),
351
+ );
352
+ }
353
+
354
+ const checkOnly = el("input", { type: "checkbox", id: "dev-check-only" });
355
+ const startGate = {
356
+ enabled: !running && !blocked && typeof view.verb === "string",
357
+ reason: blocked
358
+ ? String(view.blockedReason ?? "a supervised daemon is running")
359
+ : running
360
+ ? "a dev loop is already running"
361
+ : typeof view.verb === "string"
362
+ ? null
363
+ : String(view.note ?? "there is nothing to watch"),
364
+ };
365
+ const startBtn = gatedBtn(
366
+ "Start dev loop",
367
+ startGate,
368
+ () => {
369
+ runAction(
370
+ startBtn,
371
+ "Start dev loop",
372
+ () => api.devStart({ id: ctx.id }, { checkOnly: checkOnly.checked }),
373
+ (r) => reportWrite(r, "Start dev loop", reload),
374
+ );
375
+ },
376
+ "btn btn-primary",
377
+ );
378
+ const stopGate = {
379
+ enabled: running || supervision.stoppable === true,
380
+ reason: supervision.reason ?? "nothing to stop",
381
+ };
382
+ const stopBtn = gatedBtn("Stop", stopGate, () => {
383
+ runAction(
384
+ stopBtn,
385
+ "Stop",
386
+ () => api.devStop({ id: ctx.id }, {}),
387
+ (r) => reportWrite(r, "Stop", reload),
388
+ );
389
+ });
390
+
391
+ const reasons = el("div", { class: "proc-reasons" });
392
+ if (!startGate.enabled && startGate.reason) {
393
+ reasons.appendChild(
394
+ el("div", { class: "muted gated-why", text: `start: ${startGate.reason}` }),
395
+ );
396
+ }
397
+ if (supervision.stoppable !== true && typeof supervision.reason === "string") {
398
+ reasons.appendChild(
399
+ el("div", { class: "muted gated-why", text: `stop: ${supervision.reason}` }),
400
+ );
401
+ }
402
+
403
+ const runs = Array.isArray(view.runs) ? view.runs : [];
404
+ const card = el("section", { class: "card ov-wide" }, [
405
+ el("h3", { class: "card-title" }, [
406
+ el("span", { text: "Dev mode" }),
407
+ running ? dot("ok", `watching (${String(view.mode ?? "watch")})`) : dot("off", "idle"),
408
+ ]),
409
+ facts,
410
+ collapsible(
411
+ [el("span", { class: "muted", text: "state roots the manager anchored" })],
412
+ [rootList],
413
+ ),
414
+ el("div", { class: "proc-bar" }, [
415
+ el("div", { class: "proc-actions" }, [
416
+ el("label", { class: "muted", for: "dev-check-only" }, [
417
+ checkOnly,
418
+ el("span", { text: " validate only (compile --check --watch)" }),
419
+ ]),
420
+ startBtn,
421
+ stopBtn,
422
+ ]),
423
+ reasons,
424
+ ]),
425
+ cliTwin(
426
+ typeof view.cliTwin === "string" ? view.cliTwin : "",
427
+ "no crewhaus.yaml at the harness root — there is no command to run",
428
+ ),
429
+ ]);
430
+
431
+ if (runs.length === 0) {
432
+ card.appendChild(
433
+ emptyState(
434
+ typeof view.note === "string" && view.note !== ""
435
+ ? view.note
436
+ : "No dev loop has run for this harness",
437
+ typeof view.verb === "string" ? view.verb : null,
438
+ ),
439
+ );
440
+ } else {
441
+ card.appendChild(runsTable(ctx, runs));
442
+ }
443
+ card.appendChild(
444
+ el("div", { class: "proc-links" }, [
445
+ el("a", { href: hrefHarness(ctx.id, "runs"), text: "run history →" }),
446
+ el("a", { href: hrefHarness(ctx.id, "overview"), text: "process card →" }),
447
+ ]),
448
+ );
449
+ return card;
450
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Schedulers — the four-lane timeline (heartbeat, schedule, dream, janitor).
3
+ *
4
+ * THE ASYMMETRY IS THE SCREEN. A daemon's schedulers are in-process timers,
5
+ * so the CADENCE is declared in the spec and knowable offline, while the
6
+ * PHASE — last fired, next due — is knowable only inside the process that
7
+ * armed the timer. That is why `crewhaus.control.v1` exists, and why a lane
8
+ * with no reachable control plane says "cadence only" and names the reason
9
+ * instead of leaving a blank that reads as a gap.
10
+ *
11
+ * Two lanes can be poked (control.v1 arms them); dream and janitor are
12
+ * read-only rows that say why. Wake distinguishes `tick_in_flight` (a tick
13
+ * is running — retry) from `draining` (this daemon is going away).
14
+ */
15
+
16
+ import { api } from "../api.js";
17
+ import { clear, collapsible, dot, el, emptyState, jsonPre, skeleton } from "../dom.js";
18
+ import { laneModels } from "../supervision.js";
19
+ import { controlBtn } from "./control.js";
20
+ import { jobButton } from "./jobs.js";
21
+
22
+ export async function renderSchedulers(root, ctx) {
23
+ clear(root).appendChild(skeleton(5));
24
+ const view = await api.schedulers(ctx.id);
25
+ clear(root);
26
+ if (view === null) {
27
+ root.appendChild(emptyState("Nothing scheduled here yet", "crewhaus daemon start"));
28
+ return;
29
+ }
30
+ const reload = () => renderSchedulers(root, ctx);
31
+ const nowMs = Date.now();
32
+ const lanes = laneModels(view, nowMs);
33
+ const dir = ctx.dir ?? "";
34
+
35
+ root.appendChild(
36
+ el("div", { class: "rollup" }, [
37
+ view.controlReachable === true
38
+ ? dot("ok", "control.v1 answered — phase columns are live")
39
+ : dot("off", "no control plane — cadence only"),
40
+ view.controlReachable !== true && typeof view.controlReason === "string"
41
+ ? el("span", { class: "muted", text: view.controlReason })
42
+ : null,
43
+ view.draining === true ? el("span", { class: "chip chip-warn", text: "draining" }) : null,
44
+ ]),
45
+ );
46
+
47
+ if (lanes.length === 0) {
48
+ root.appendChild(emptyState("This spec declares no scheduler lanes", "crewhaus compile"));
49
+ return;
50
+ }
51
+
52
+ const grid = el("div", { class: "lanes" });
53
+ // The fleet-level reason is already on the line above; repeating it in all
54
+ // four lanes buries the per-lane facts under one sentence said four times.
55
+ const headerReason = typeof view.controlReason === "string" ? view.controlReason : null;
56
+ for (const lane of lanes) grid.appendChild(laneCard(lane, headerReason, ctx, dir, reload));
57
+ root.appendChild(grid);
58
+
59
+ if (view.counters && typeof view.counters === "object") {
60
+ root.appendChild(
61
+ collapsible(
62
+ [el("span", { class: "muted", text: "control.v1 counters" })],
63
+ [jsonPre(view.counters)],
64
+ ),
65
+ );
66
+ }
67
+ }
68
+
69
+ function laneCard(lane, headerReason, ctx, dir, reload) {
70
+ const phase = el("div", { class: "lane-phase" }, [
71
+ el("div", { class: "kv" }, [
72
+ el("span", { class: "kv-k", text: "cadence" }),
73
+ el("span", { class: "kv-v" }, [
74
+ el("span", { text: lane.cadence ?? "—" }),
75
+ el("span", { class: "chip", text: `from ${lane.cadenceSource}` }),
76
+ ]),
77
+ ]),
78
+ el("div", { class: "kv" }, [
79
+ el("span", { class: "kv-k", text: "last fired" }),
80
+ el("span", { class: "kv-v" }, [
81
+ el("span", { text: lane.lastFiredLabel }),
82
+ lane.lastOutcome !== null ? el("span", { class: "chip", text: lane.lastOutcome }) : null,
83
+ ]),
84
+ ]),
85
+ el("div", { class: "kv" }, [
86
+ el("span", { class: "kv-k", text: "next due" }),
87
+ el("span", { class: "kv-v" }, [
88
+ el("span", { text: lane.nextDueLabel }),
89
+ // Honest about WHY the phase is missing — never an implied gap. The
90
+ // fleet-level reason is on the header line, so only a LANE-specific
91
+ // one repeats here.
92
+ lane.phaseNote !== null && lane.phaseNote !== headerReason
93
+ ? el("div", { class: "muted reason", text: lane.phaseNote })
94
+ : null,
95
+ ]),
96
+ ]),
97
+ ]);
98
+
99
+ const actions = el("div", { class: "lane-actions" });
100
+ if (lane.lane === "heartbeat" || lane.lane === "schedule") {
101
+ actions.appendChild(
102
+ controlBtn(
103
+ "Wake",
104
+ { enabled: lane.pokeable, reason: lane.pokeReason },
105
+ () => api.controlWake(ctx.id, lane.lane, "poked from the Hangar console"),
106
+ { action: "wake", dir, lane: lane.lane },
107
+ ),
108
+ );
109
+ } else if (lane.lane === "dream") {
110
+ // Dream is a read-only timer row in control.v1 — the honest "run now" is
111
+ // the job queue, not a poke.
112
+ actions.appendChild(jobButton("Run now", ctx.id, "dream-run", undefined, reload));
113
+ if (lane.pokeReason !== null) {
114
+ actions.appendChild(el("div", { class: "muted gated-why", text: lane.pokeReason }));
115
+ }
116
+ } else if (lane.pokeReason !== null) {
117
+ actions.appendChild(el("div", { class: "muted gated-why", text: lane.pokeReason }));
118
+ }
119
+
120
+ const detailNode =
121
+ lane.detail !== null && typeof lane.detail === "object"
122
+ ? collapsible([el("span", { class: "muted", text: "lane detail" })], [jsonPre(lane.detail)])
123
+ : null;
124
+
125
+ return el("section", { class: `card lane-card${lane.armed ? "" : " lane-off"}` }, [
126
+ el("h3", { class: "card-title" }, [el("span", { text: lane.lane }), dot(lane.dot, lane.label)]),
127
+ lane.armed
128
+ ? phase
129
+ : el("p", { class: "muted", text: "the spec declares no such block — absent, not broken" }),
130
+ lane.armed ? actions : null,
131
+ lane.armed ? detailNode : null,
132
+ ]);
133
+ }