@adrrr/tarmac 0.2.0 → 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.
package/dist/render.js CHANGED
@@ -7,7 +7,10 @@
7
7
  // stays invisible for days. The two surfaces say the same things in their own words — so
8
8
  // they are written side by side, and the suite can reach both.
9
9
  import { formatDuration } from './config.js';
10
+ import { buildMap, INTERACTIVE, stateOf } from './map.js';
10
11
  import { schemaNotice } from './schema.js';
12
+ import { LIMIT_WINDOWS, RESET_HORIZON_MS, readLimits } from './limits.js';
13
+ import { accountLimits } from './fleet.js';
11
14
  /**
12
15
  * The other thing this module renders: the plan a user consents to before install or
13
16
  * uninstall touches their settings.json. Everything the decision rests on has to be here —
@@ -52,9 +55,16 @@ export function renderPlan(plan) {
52
55
  }
53
56
  else {
54
57
  rows.push(['restore', `${plan.mode} — ${restoreMeaning(plan.mode)}`]);
55
- // "Your snapshots survive uninstall" is a promise about a directory nobody can guess any
56
- // more, so the command that leaves them behind says where it leaves them.
57
- rows.push(['snapshots', `${plan.snapshots} (left exactly as they are)`]);
58
+ // Nothing here says where the payloads are no wrapper, or one that no longer carries
59
+ // the path, or one we cannot read. There is no directory to name, and `uninstall` opens
60
+ // none and removes nothing in one. Printing the default we would have computed, beside a
61
+ // promise to clear a marker out of it, is a plan disagreeing with what runs.
62
+ if (plan.snapshots === null) {
63
+ rows.push(['snapshots', 'unknown — nothing here says where; nothing there is opened or removed']);
64
+ }
65
+ else {
66
+ rows.push(['snapshots', `${plan.snapshots} (snapshot files stay; ${markerFate(plan.mode, plan.marker)})`]);
67
+ }
58
68
  }
59
69
  rows.push(['undo', plan.undo]);
60
70
  const w = Math.max(...rows.map(([label]) => label.length));
@@ -75,6 +85,26 @@ const gitHint = (repo, hasLegacy) => `${repo.dir} is a git repository — ` +
75
85
  (hasLegacy
76
86
  ? `commit the removal above, and add \`${repo.ignore}\` to its .gitignore`
77
87
  : 'nothing tarmac writes there changes at runtime; the snapshots live outside it');
88
+ /**
89
+ * What becomes of the prune marker, said only after looking at it.
90
+ *
91
+ * Three of these four answers are "it stays", each for its own reason: a foreign statusLine
92
+ * keeps the wrapper, so the marker keeps its owner; nothing is there to take; or what is there
93
+ * is not a plain file, which `removePruneMarker` refuses by design because `unlink` would take
94
+ * a link and not its target.
95
+ *
96
+ * Written so that ONLY `'file'` can reach the removal sentence, rather than letting it be the
97
+ * fallthrough: `marker` is nullable by type, and a null landing on "is removed" would be the
98
+ * exact promise this whole change exists to stop making. The safe answer is the default; the
99
+ * dangerous one is the special case.
100
+ */
101
+ const markerFate = (mode, marker) => marker !== 'file'
102
+ ? marker === 'not-a-file'
103
+ ? "the prune marker's name is worn by something that is not a regular file, so it stays"
104
+ : 'no prune marker to remove'
105
+ : mode === 'foreign'
106
+ ? "tarmac's prune marker stays"
107
+ : "tarmac's prune marker is removed";
78
108
  /** What each restore mode means, in the words the plan and the report both use. */
79
109
  export const restoreMeaning = (mode) => RESTORE_MEANING[mode];
80
110
  const RESTORE_MEANING = {
@@ -119,7 +149,7 @@ export function renderTable({ rows, health }) {
119
149
  const head = ['PROJECT', 'STATE', 'CTX', 'AS OF', 'MODEL', 'EFFORT', 'COST', 'UP'];
120
150
  const body = rows.map((r) => [
121
151
  r.project ?? '—',
122
- r.busy === true ? 'busy' : r.busy === false ? 'idle' : `?${r.status ?? ''}`,
152
+ stateCell(r),
123
153
  r.ctxPct === null ? `— ${r.ctxState}` : `${r.ctxPct}%`,
124
154
  // The age of the reading, never implied to be "now".
125
155
  r.snapshotAgeMs === null ? '—' : ahead(r) ? '— ahead' : `${age(r.snapshotAgeMs)}${r.stale ? ' !' : ''}`,
@@ -148,7 +178,13 @@ export function renderTable({ rows, health }) {
148
178
  else if (health.schemaBroken)
149
179
  warns.push('! every snapshot drifted — the statusline payload schema moved');
150
180
  else if (health.covered < health.sessions)
151
- warns.push(`! statusline chained on ${health.covered}/${health.sessions} sessions`);
181
+ warns.push(
182
+ // The count travels, for the same reason `unreadable` does one line up: without it
183
+ // this line reads as "run install", and for a session id the wrapper declines to file
184
+ // that is advice already taken which can never work.
185
+ health.unfilable > 0
186
+ ? `! statusline chained on ${health.covered}/${health.sessions} sessions — ${health.unfilable} session(s) with an id tarmac never files`
187
+ : `! statusline chained on ${health.covered}/${health.sessions} sessions`);
152
188
  if (health.stale > 0)
153
189
  warns.push(`! ${health.stale} reading(s) marked "!" are older than ${formatDuration(health.staleAfterMs)} (--stale-after)`);
154
190
  const skewed = rows.filter(ahead).length;
@@ -166,6 +202,21 @@ export function renderTable({ rows, health }) {
166
202
  (warns.length ? '\n' + warns.join('\n') + '\n' : '') +
167
203
  `\n${health.sessions} sessions · ${health.busy} busy · ${total}\n`);
168
204
  }
205
+ /**
206
+ * The STATE column, out of the same verdict the page draws from.
207
+ *
208
+ * `?` is this column's word for "a status tarmac does not recognise", so it may not lead the
209
+ * one status tarmac knows by name and now has a state for. The reason follows the word, in
210
+ * the separator this renderer already uses for two facts on one line — and it is the only
211
+ * value here that can widen a column: it does so on a fleet that has a session blocked on a
212
+ * human, which is the fleet you wanted it on.
213
+ */
214
+ function stateCell(r) {
215
+ const state = stateOf(r);
216
+ if (state === 'unknown')
217
+ return `?${r.status ?? ''}`;
218
+ return state === 'waiting' && r.waitingFor ? `waiting · ${r.waitingFor}` : state;
219
+ }
169
220
  /**
170
221
  * A snapshot dated AFTER the clock we are reading it with — a mount whose time runs ahead, an
171
222
  * NTP correction between the write and the read. Its age is not a small number, it is not a
@@ -194,7 +245,8 @@ function age(ms) {
194
245
  * could not see) are hard-won and tested; re-deriving them in browser JavaScript to redraw a
195
246
  * polled row would put the second copy somewhere this suite cannot reach.
196
247
  */
197
- export function renderLive({ rows, health }) {
248
+ export function renderLive(fleet) {
249
+ const { rows, health } = fleet;
198
250
  const warnings = [];
199
251
  if (health.noSessionId > 0) {
200
252
  // Never "no sessions found" when discovery DID find some it could not identify.
@@ -216,7 +268,12 @@ export function renderLive({ rows, health }) {
216
268
  warnings.push(`Every snapshot drifted — Claude Code's statusline schema has probably moved. Context readings are dead until the payload shape is re-checked.`);
217
269
  }
218
270
  else if (health.covered < health.sessions) {
219
- warnings.push(`Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`);
271
+ const blind = health.sessions - health.covered;
272
+ warnings.push(health.unfilable === 0
273
+ ? `Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`
274
+ : health.unfilable >= blind
275
+ ? `Statusline chained on ${health.covered}/${health.sessions} sessions — the rest carry a session id that is not the UUID tarmac files snapshots under, so no frame will ever produce one. Installing again will not change that.`
276
+ : `Statusline chained on ${health.covered}/${health.sessions} sessions — ${blind} report no context, and ${health.unfilable} of them will never be filed: the session id is not the UUID tarmac files snapshots under. For the others, run \`tarmac install\` and give them one TUI frame.`);
220
277
  }
221
278
  if (health.unknownStatus > 0) {
222
279
  warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
@@ -231,22 +288,110 @@ export function renderLive({ rows, health }) {
231
288
  const schema = schemaNotice(health.schemaGuard);
232
289
  if (schema)
233
290
  warnings.push(schema);
291
+ // Both views, every time, out of the one reading the page just asked for. The tabs are
292
+ // links and the shell decides which of the two is visible, so a fleet cannot be drawn as a
293
+ // table of one age beside a map of another.
294
+ //
295
+ // A fleet with nothing in it has no two ways to be laid out, so it gets one sentence above
296
+ // both of them rather than a copy inside each — the copy behind `display:none` was invisible
297
+ // on screen and read out all the same by anything going through the markup.
234
298
  const body = rows.length === 0
235
- ? health.noSessionId > 0
236
- ? // Discovery DID return entries — we just could not identify them. Saying "none
237
- // found" here would hide a schema change behind a calm, wrong answer.
238
- `<p class="empty">No session could be identified, though ${health.noSessionId} were discovered.</p>`
239
- : `<p class="empty">No Claude Code sessions found. Is a session running?</p>`
240
- : `<table>
299
+ ? empty(health)
300
+ : `<div class="view view-table"><div class="wrap"><table>
241
301
  <thead><tr>
242
302
  <th>Project</th><th>Session</th><th>State</th><th>Context</th><th>Model</th><th>Effort</th><th>Cost</th><th>Uptime</th>
243
303
  </tr></thead>
244
304
  <tbody>${rows.map(renderRow).join('')}</tbody>
245
- </table>`;
246
- return `<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
305
+ </table></div></div>
306
+ <div class="view view-map">${renderMap(fleet)}</div>`;
307
+ return `<div id="limits-src" hidden>${renderLimits(fleet)}</div>
308
+ <div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
247
309
  ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
248
- <div class="wrap">${body}</div>`;
310
+ ${body}`;
311
+ }
312
+ /**
313
+ * The account's two windows, for the page's header.
314
+ *
315
+ * They are the one pair of numbers here that is not about a session: every session on the page
316
+ * spends from the same five-hour and seven-day allowance, so the gauges sit at the top of the
317
+ * page rather than on a node — and the fleet's own rule decides whose reading counts when the
318
+ * sessions carry the same number at different ages.
319
+ *
320
+ * Rendered into the FRAGMENT as well as into the shell, in a slot the script copies up on every
321
+ * swap. The header is the shell's — it has to survive a poll, the tabs and a replay — but the
322
+ * numbers are the fleet's, and the fleet is what the fragment carries. A gauge left in the shell
323
+ * alone would be as old as the tab.
324
+ */
325
+ export function renderLimits({ rows, health }) {
326
+ const account = accountLimits(rows);
327
+ const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt)
328
+ .map(gauge)
329
+ .join('');
330
+ // Dated when the snapshot behind it is past the threshold, exactly as the table dates a stale
331
+ // context. It matters more here than anywhere else on the page: the percentage is as old as
332
+ // that snapshot, while the countdown beside it is recomputed on every five-second re-render —
333
+ // so an undated pair puts a frozen number next to a visibly moving one and lets the reader
334
+ // assume both are now.
335
+ //
336
+ // Once for the two, not once each: both windows come out of the SAME snapshot, and the same
337
+ // fact said twice is noise. The replay has no equivalent — the ring keeps each reading and
338
+ // never how old it was, which is why nothing replayed on this page is dated.
339
+ const stale = account !== null && account.ageMs > health.staleAfterMs;
340
+ return gauges + (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '');
341
+ }
342
+ /**
343
+ * One window. Four things in a line: which window it is, a bar for the glance, the number that
344
+ * is authoritative, and how long is left. The bar is `aria-hidden` because it says nothing the
345
+ * number does not, and the abbreviation is replaced rather than doubled for a reader who hears
346
+ * the page — "5h" is a label on a screen and a syllable in an ear.
347
+ */
348
+ function gauge(g) {
349
+ // No fill, ever, for a window nobody read: an empty bar is what an account at 0% wears, and
350
+ // "I could not look" must not be able to wear it. The same dotted emptiness as an unmeasured
351
+ // dial, in the shape a bar has.
352
+ const rail = g.pct === null
353
+ ? `<span class="rail unmeasured" aria-hidden="true"></span>`
354
+ : `<span class="rail" aria-hidden="true"><i style="width:${g.pct}%"></i></span>`;
355
+ return (`<div class="gauge"><span class="lbl" aria-hidden="true">${g.label}</span><span class="sr">${g.said}</span>` +
356
+ `${rail}<span class="num">${g.pct === null ? dash() : `${g.pct}%`}</span>` +
357
+ `<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs)}</span></div>`);
358
+ }
359
+ /** Which kind of missing a missing window is, in the two words both surfaces use. */
360
+ const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
361
+ /**
362
+ * The reset, as a stretch of time rather than as the epoch the payload carries.
363
+ *
364
+ * A negative one is not a countdown to be printed with a minus sign: the window rolled over
365
+ * after the reading that reported it, so the percentage beside these words belongs to a window
366
+ * that no longer exists. Saying that is the whole point of showing a reset at all.
367
+ */
368
+ const resetWords = (ms) => ms === null ? `reset ${dash()}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
369
+ /**
370
+ * How long, in the two units that matter at each scale. Deliberately finer than `duration()`
371
+ * next door, which floors a session's uptime to whole hours: five hours is a window someone
372
+ * plans the next hour around, and "resets in 2h" said anywhere between 2h00 and 2h59 is the
373
+ * kind of rounding that makes a reader stop believing the number.
374
+ */
375
+ function left(ms) {
376
+ const m = Math.floor(ms / 60000);
377
+ if (m < 1)
378
+ return '<1m';
379
+ if (m < 60)
380
+ return `${m}m`;
381
+ const h = Math.floor(m / 60);
382
+ if (h < 24)
383
+ return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
384
+ const d = Math.floor(h / 24);
385
+ return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
249
386
  }
387
+ /**
388
+ * What to say instead of a fleet. Discovery returning entries we could not identify is not
389
+ * an empty fleet, and saying "none found" would hide a schema change behind a calm, wrong
390
+ * answer — so the two surfaces below share one wording rather than each keeping its own.
391
+ */
392
+ const empty = (health) => health.noSessionId > 0
393
+ ? `<p class="empty">No session could be identified, though ${health.noSessionId} were discovered.</p>`
394
+ : `<p class="empty">No Claude Code sessions found. Is a session running?</p>`;
250
395
  /**
251
396
  * One frame of `tarmac list --watch`. It owes the reader exactly what the page owes: the
252
397
  * table, when the reading in it arrived, and whether the last attempt to refresh it failed.
@@ -293,15 +438,21 @@ function ago(ms) {
293
438
  const m = Math.round(s / 60);
294
439
  return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
295
440
  }
296
- export function renderPage(fleet) {
441
+ export function renderPage(fleet, view = 'table') {
442
+ // The header's copy. `renderLive` below renders its own, out of this same fleet and through
443
+ // this same function — two calls of one pure renderer over one reading, which is what keeps
444
+ // the pair the reader sees and the pair the script will copy up from being two accounts.
445
+ const gauges = renderLimits(fleet);
297
446
  return `<!doctype html>
298
447
  <html lang="en"><head>
299
448
  <meta charset="utf-8">
300
449
  <meta name="viewport" content="width=device-width,initial-scale=1">
301
450
  <title>tarmac — fleet</title>
302
451
  <style>
303
- :root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; }
304
- @media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; } }
452
+ /* --wait is a fourth hue rather than the warning one: a session blocked on a human is not
453
+ a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
454
+ :root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8; }
455
+ @media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; --wait:#93c5fd; } }
305
456
  body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
306
457
  font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
307
458
  header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
@@ -325,11 +476,13 @@ export function renderPage(fleet) {
325
476
  .pill { display:inline-block; font-size:.8rem; font-weight:600; padding:.05rem .5rem;
326
477
  border:1px solid currentColor; border-radius:99px; white-space:nowrap; }
327
478
  .pill.busy { color:var(--busy); }
479
+ .pill.waiting { color:var(--wait); }
328
480
  .pill.unknown { color:var(--warn); }
329
481
  .pill.idle { color:var(--dim); font-weight:400; }
330
482
  /* The weight the sort deserves: busy rows carry an accent and a bold name. */
331
483
  td:first-child { border-left:3px solid transparent; }
332
484
  tr[data-state="busy"] td:first-child { border-left-color:var(--busy); }
485
+ tr[data-state="waiting"] td:first-child { border-left-color:var(--wait); }
333
486
  tr[data-state="unknown"] td:first-child { border-left-color:var(--warn); }
334
487
  tr[data-state="busy"] .project { font-weight:700; }
335
488
  /* The bar reads a magnitude at a glance; the number beside it is what is authoritative.
@@ -344,6 +497,157 @@ export function renderPage(fleet) {
344
497
  body.failing .pulse { background:var(--warn); }
345
498
  body.failing #live { border:1px dashed var(--warn); border-radius:8px; padding:.5rem; }
346
499
  .offline strong { white-space:nowrap; }
500
+ /* The tabs, and what they hide. The shell owns the choice — not the fragment — so a poll
501
+ that swaps the fleet underneath cannot put the reader back on a view they left. */
502
+ nav { display:flex; gap:.15rem; }
503
+ nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
504
+ letter-spacing:.06em; padding:.15rem .55rem; border-radius:99px; border:1px solid transparent; }
505
+ nav a[aria-current="page"] { color:var(--fg); border-color:var(--line); }
506
+ body[data-view="table"] .view-map { display:none; }
507
+ body[data-view="map"] .view-table { display:none; }
508
+
509
+ /* ── the account's two windows ───────────────────────────────────────────────────────
510
+ In the header, because a rate limit is the account's and not a node's. Slim on purpose:
511
+ the fleet is what the page is about, and these two numbers are the weather it flies in.
512
+ Laid out with flex behind the same :not([hidden]) guard the replay containers carry —
513
+ the replayed pair ships hidden, and a display in a stylesheet beats the attribute. */
514
+ .limits:not([hidden]) { display:flex; gap:1rem; flex-wrap:wrap; align-items:center; }
515
+ .gauge { display:flex; align-items:baseline; gap:.35rem; font-size:.8rem; }
516
+ /* Not upper-cased, alone among the small labels on this page: "5H" is not an hour, and a
517
+ unit that has been shouted reads as a different unit. */
518
+ .gauge .lbl { color:var(--dim); font-weight:600; letter-spacing:.04em; }
519
+ .gauge .num { font-variant-numeric:tabular-nums; font-weight:650; }
520
+ .gauge .reset { color:var(--dim); }
521
+ /* Same bargain as the row bars: a glance at a magnitude, in the quiet ink of a secondary
522
+ fact, beside the number that is the authority. Its own class rather than .bar — that one
523
+ is dropped below 46rem, where a card layout gives every value the name of its column, and
524
+ these two have no column to be named by. */
525
+ .gauge .rail { display:inline-block; width:3.5rem; height:.3rem; border-radius:99px;
526
+ background:var(--line); align-self:center; }
527
+ .gauge .rail > i { display:block; height:100%; border-radius:99px; background:var(--dim); }
528
+ /* Nothing was measured — the dotted track of an unmeasured dial, in the shape of a bar. An
529
+ empty rail is what an account at 0% wears, and the two must not match. */
530
+ .gauge .rail.unmeasured { background:repeating-linear-gradient(90deg,var(--line) 0 2px,transparent 2px 8px); }
531
+ /* The live pair goes down with the live fragment: they are about now, and left up they would
532
+ be the one present-tense number standing over a fleet three hours old. */
533
+ body.replaying #limits { display:none; }
534
+ /* The replayed pair leads the past fleet rather than sitting on top of its totals. */
535
+ #replay-limits { margin-bottom:.2rem; }
536
+
537
+ /* ── the scrubber ────────────────────────────────────────────────────────────────────
538
+ Under the map, and only under the map: the record holds what the MAP draws, so a
539
+ scrubber over the table would offer a drag onto rows it cannot fill.
540
+ Everything here lives in the shell for the same reason the tabs do: the /live fragment is
541
+ swapped into innerHTML every five seconds, and a handle inside it would be dragged back
542
+ to the present by a poll nobody asked for. */
543
+ body[data-view="table"] #replay, body[data-view="table"] #replay-view { display:none; }
544
+ /* One fleet at a time, and the whole fragment rather than only its map: the fragment's
545
+ header is the LIVE count, cost and timestamp, and hiding the map alone left it sitting
546
+ directly above the replayed one — two totals of two different moments, the pair dated
547
+ with the present. The warnings above them are about the present too. The failure banner
548
+ is in the shell, so a refresh that breaks mid-replay still says so. */
549
+ body.replaying #live { display:none; }
550
+ /* Both of these are laid out with flex, and both are hidden by the attribute until a script
551
+ raises them — so the display is refused to a hidden one explicitly. The hidden attribute
552
+ is only a UA rule of display:none, and any display a stylesheet gives the same element
553
+ beats it: unguarded, this page came up announcing a replay nobody had asked for. */
554
+ .replay:not([hidden]) { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-top:1rem;
555
+ padding-top:.7rem; border-top:1px solid var(--line); }
556
+ .replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
557
+ border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
558
+ .replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
559
+ .replay input[type="range"]:disabled { opacity:.4; }
560
+ /* The two things the reader has to be able to read while dragging: the minute under the
561
+ handle, and what the whole range covers. Tabular, so neither jitters as it counts. */
562
+ #replay-at { font-variant-numeric:tabular-nums; font-weight:600; }
563
+ .replay .covers { flex-basis:100%; color:var(--dim); font-size:.75rem; }
564
+ /* The banner wears the warning style on purpose: a page showing a past minute as though it were the
565
+ fleet is the worst thing this dashboard could do, so it wears the loudest thing it has. */
566
+ /* Sticky, because the handle is at the bottom of a map that can be taller than the
567
+ viewport: a reader dragging with the "this is the past" banner scrolled off the top is
568
+ a reader the banner is not warning. */
569
+ .replaying-note:not([hidden]) { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap;
570
+ position:sticky; top:0; z-index:1; }
571
+ .replaying-note button { font:inherit; font-size:.75rem; font-weight:600; color:inherit;
572
+ background:transparent; border:1px solid currentColor; border-radius:99px;
573
+ padding:.05rem .7rem; cursor:pointer; }
574
+
575
+ /* ── the map ─────────────────────────────────────────────────────────────────────────
576
+ One node per session. The arc is the context, its weight is how much that reading may
577
+ be believed, and the halo — the only thing on this page that moves — says a frame
578
+ landed moments ago. */
579
+ .map { display:grid; gap:.9rem; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr)); }
580
+ .node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
581
+ display:flex; flex-direction:column; align-items:center; text-align:center; }
582
+ .node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
583
+ .node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
584
+ /* An agent is a smaller body in the same system, next to the session it shares a directory
585
+ with — never inside it. Tinted rather than outlined, and hooked, so it reads as the
586
+ session's dependent without a line claiming a parentage the source never published. */
587
+ .node[data-role="agent"] { padding-top:.55rem; border-color:transparent;
588
+ background:color-mix(in srgb, var(--line) 22%, transparent); }
589
+ .node[data-role="agent"] .dial { width:3.6rem; height:3.6rem; }
590
+ .node[data-role="agent"] .pct { font-size:.95rem; }
591
+ .node[data-role="agent"] .why { font-size:.6rem; max-width:3.4rem; }
592
+ .node[data-role="agent"] .why b { font-size:1rem; }
593
+ .node[data-role="agent"] .project { font-weight:400; }
594
+ /* Said, not shown: the three glyphs differ in silhouette, so a reader who cannot separate
595
+ two hues still has the state — but a screen reader is handed a bullet and nothing else. */
596
+ .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
597
+ .dial { position:relative; width:5.5rem; height:5.5rem; }
598
+ .dial svg { width:100%; height:100%; display:block; overflow:visible; }
599
+ .track { fill:none; stroke:var(--line); stroke-width:5; }
600
+ /* Butt caps, not round: a rounded cap adds half a stroke width at each end, which draws a
601
+ 1% reading at three times its extent. The prettier cap overstates every small number. */
602
+ .arc { fill:none; stroke:var(--dim); stroke-width:5; }
603
+ .node[data-state="busy"] .arc { stroke:var(--busy); }
604
+ /* A reading past the freshness threshold is drawn as what it is: thin, faded, and in the
605
+ warning hue — never the solid arc of a live one. Its EXTENT stays true, because the
606
+ number is still the truth of an earlier moment; and a dash pattern here would overwrite
607
+ stroke-dasharray, which is what carries the percentage. */
608
+ .node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
609
+ stroke:var(--warn); stroke-width:2.5; opacity:.7; }
610
+ /* A replayed reading. Its EXTENT is what the record vouches for; its age is the one thing
611
+ the ring never kept, so it may not wear the solid arc that means "as current as a reading
612
+ gets" — nor the warning hue of a stale one, which would claim the opposite. Between the
613
+ two: full colour, a shade lighter, and no date underneath it. */
614
+ .node[data-reading="undatable"] .arc { stroke-width:4; opacity:.85; }
615
+ /* Nothing was measured. Keyed on the measurement and never on the age of the file: a
616
+ solid empty ring is what a session measured at 0% wears, and the two must not match. */
617
+ .track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
618
+ /* Once per arrival, not forever: the fragment is replaced on every poll, so a single run
619
+ per swap is what makes the fleet breathe at the rate its frames actually land. A looping
620
+ animation would say "a frame just arrived" for five seconds after it stopped being true. */
621
+ .halo { fill:none; stroke:var(--busy); stroke-width:2; opacity:0; transform-origin:50% 50%;
622
+ animation:halo 1.6s ease-out 1; }
623
+ .node[data-state="idle"] .halo { stroke:var(--dim); }
624
+ @keyframes halo { from { opacity:.5; transform:scale(1); } to { opacity:0; transform:scale(1.22); } }
625
+ /* Motion is the one thing here nobody can look away from, so it is the first thing a
626
+ reader who asked for less of it stops getting. The reading is still readable without it. */
627
+ /* Scaled, not merely stopped: at rest the halo sits inside the track's own stroke, so
628
+ "animation:none" alone left the one fact it carries invisible. */
629
+ @media (prefers-reduced-motion: reduce) { .halo { animation:none; opacity:.35; transform:scale(1.18); } }
630
+ .val { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
631
+ font-variant-numeric:tabular-nums; }
632
+ .pct { font-size:1.25rem; font-weight:650; letter-spacing:-.01em; }
633
+ .pct i { font-style:normal; font-size:.7em; font-weight:500; color:var(--dim); }
634
+ .why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
635
+ .why b { display:block; font-size:1.25rem; font-weight:400; }
636
+ .who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
637
+ .who .project { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
638
+ .node[data-state="busy"] .who .project { font-weight:700; }
639
+ .shape { font-size:.7rem; color:var(--dim); }
640
+ .node[data-state="busy"] .shape { color:var(--busy); }
641
+ .node[data-state="waiting"] .shape { color:var(--wait); }
642
+ .node[data-state="unknown"] .shape { color:var(--warn); }
643
+ .sub { color:var(--dim); font-size:.76rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
644
+ /* The one caption that is not a footnote: it is why this node is not working, and it sits
645
+ directly under the name in the state's own hue rather than in the grey of the rest. */
646
+ .sub.waiting-for { color:var(--wait); font-weight:600; }
647
+ .asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
648
+ .asof.stale { color:var(--warn); font-weight:600; }
649
+ @media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
650
+
347
651
  /* Below this the table stops being a table: one card per session, every value keeping the
348
652
  name of the column it came from. Nothing is dropped — a phone that hides the context
349
653
  column would be a phone that renders "not measured" as nothing at all. */
@@ -356,6 +660,7 @@ export function renderPage(fleet) {
356
660
  tr { border:1px solid var(--line); border-left-width:3px; border-radius:8px;
357
661
  padding:.35rem .7rem; margin-bottom:.6rem; }
358
662
  tr[data-state="busy"] { border-left-color:var(--busy); }
663
+ tr[data-state="waiting"] { border-left-color:var(--wait); }
359
664
  tr[data-state="unknown"] { border-left-color:var(--warn); }
360
665
  td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
361
666
  display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
@@ -365,9 +670,22 @@ export function renderPage(fleet) {
365
670
  .bar { display:none; }
366
671
  }
367
672
  </style>
368
- </head><body>
673
+ </head><body data-view="${view}">
369
674
  <header>
370
675
  <h1>tarmac</h1>
676
+ <!-- Links, not buttons: the view survives a reload, a bookmark and a browser with
677
+ JavaScript off — the state of a page whose own noscript banner promises it is still
678
+ readable. Both views are in the fragment below either way, so switching costs the
679
+ server nothing and the two can never show readings of different ages. -->
680
+ <nav>
681
+ <a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
682
+ <a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
683
+ </nav>
684
+ <!-- The account's two windows, page-level because that is what they are: a limit belongs to
685
+ the account every session below is spending from, not to any one of them. Their VALUES
686
+ come up from the fragment on every poll (the script's limits-src copy), so the header
687
+ structure can be the shell's without the numbers being as old as the tab. -->
688
+ <div class="limits" id="limits" role="group" aria-label="account rate limits">${gauges}</div>
371
689
  <!-- Not "updated just now". If the script never runs — a policy-injected CSP without
372
690
  'unsafe-inline', a script error — that text would stand as a permanent lie, and
373
691
  <noscript> would not fire to correct it because JavaScript is enabled. The page's one
@@ -375,12 +693,42 @@ export function renderPage(fleet) {
375
693
  <span class="freshness"><span class="pulse" aria-hidden="true"></span><span id="age">updated &mdash;</span></span>
376
694
  </header>
377
695
  <div class="warn offline" id="offline" hidden>
378
- <strong>&#9888; refresh failing</strong> — nothing below has moved since the time in the header.
696
+ <strong>&#9888; refresh failing</strong> — nothing on this page has moved since the time in the header.
379
697
  <span id="why"></span>
380
698
  </div>
699
+ <!-- The one claim on this page that could be a lie, so it is the loudest element on it and it
700
+ carries the minute it is showing. Hidden until a script raises it: with no script there
701
+ is no replay, and a banner about one would be a warning about nothing. -->
702
+ <!-- role="status" because it appears without a reload and without focus moving: drawn only,
703
+ it is the page's loudest claim and its most invisible one. -->
704
+ <div class="warn replaying-note" id="replaying" role="status" hidden>
705
+ <strong>&#9209; replaying <span id="replay-at"></span></strong>
706
+ <span>&mdash; a reading from the past, not the fleet now.</span>
707
+ <button type="button" id="to-live">Back to live</button>
708
+ </div>
381
709
  <noscript><div class="warn">JavaScript is off, so this page will not refresh itself. Reload it to see the fleet now.</div></noscript>
382
710
  <div id="live">${renderLive(fleet)}</div>
383
- <script>${SCRIPT}</script>
711
+ <!-- Where the past is drawn: the shell's own map, in the place the live one occupies, so
712
+ that swapping the fragment underneath cannot repaint what the reader is scrubbing. -->
713
+ <div id="replay-view" hidden>
714
+ <!-- The same pair, for the minute under the reader's hand — and here rather than in the
715
+ header, where the live pair sits. The banner that says "this is the past" is below the
716
+ header: an account drawn above it would be the one past number on the page with nothing
717
+ over it saying so, and the first thing a screen reader reaches, long before the warning.
718
+ Hidden until a script raises it: with no script there is no replay, and an empty gauge
719
+ would be a claim about nothing. -->
720
+ <div class="limits" id="replay-limits" role="group" aria-label="account rate limits, at the minute being replayed" hidden></div>
721
+ <div class="meta" id="replay-meta"></div>
722
+ <div class="map" id="replay-map"></div>
723
+ </div>
724
+ <!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
725
+ what it says it covers is whatever the record answered with. -->
726
+ <div class="replay" id="replay" hidden>
727
+ <button type="button" id="play">Play</button>
728
+ <input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
729
+ <div class="covers" id="covers"></div>
730
+ </div>
731
+ <script>${pageScript(view)}</script>
384
732
  </body></html>
385
733
  `;
386
734
  }
@@ -396,9 +744,15 @@ export function renderPage(fleet) {
396
744
  * polled for a reader who is not there. A poll is the only one of the three where the client
397
745
  * decides — so a hidden tab simply stops asking, and a waking one asks at once.
398
746
  *
399
- * The page therefore owns exactly two facts: when it last heard from the server, and whether
400
- * the last attempt failed. Everything a reader interprets is rendered by `renderLive` on the
401
- * server, where the suite can reach it.
747
+ * The page therefore owns exactly two facts about the present: when it last heard from the
748
+ * server, and whether the last attempt failed. Everything a reader interprets about NOW is
749
+ * rendered by `renderLive` on the server, where the suite can reach it.
750
+ *
751
+ * The replay below is the one exception, and it is one the issue asks for: scrubbing a day
752
+ * has to be a lookup in samples the page already holds, or every pixel of a drag would be a
753
+ * request and a `claude agents --json` behind it. So a second, smaller renderer lives in the
754
+ * browser — fed the same three words, the same three glyphs and the same dial geometry as the
755
+ * server's, by interpolation rather than by copy, and executed by `test/replay-script`.
402
756
  */
403
757
  export const REFRESH_MS = 5000;
404
758
  /**
@@ -407,10 +761,30 @@ export const REFRESH_MS = 5000;
407
761
  * on the server side first and arrives with a real reason instead of this generic one.
408
762
  */
409
763
  const STALL_MS = 20000;
410
- const SCRIPT = `
764
+ /**
765
+ * How fast play walks the record — one reading per step, so a serve that has seen ten minutes
766
+ * plays for a second and a full day for two and a half minutes. It is a step interval and not
767
+ * a total duration on purpose: the samples are not evenly spaced (a minute the collector
768
+ * missed is a minute nobody recorded), so a fixed run time would silently speed up over the
769
+ * gaps and make the day look busier than it was.
770
+ */
771
+ const PLAY_STEP_MS = 100;
772
+ /**
773
+ * The same walk for a reader who asked their system for less motion. Play is the one thing
774
+ * here that moves, and the honest answer to that preference is not to take the feature away —
775
+ * it is to stop flickering ten frames a second at someone who said that hurts.
776
+ */
777
+ const PLAY_STEP_CALM_MS = 1000;
778
+ /**
779
+ * A function, not a constant: it reads the vocabulary and the geometry declared below it, and
780
+ * it takes the view because only one of the two has a scrubber to feed.
781
+ */
782
+ function pageScript(view) {
783
+ return `
411
784
  (function () {
412
785
  var live = document.getElementById('live'), age = document.getElementById('age');
413
786
  var off = document.getElementById('offline'), why = document.getElementById('why');
787
+ var limits = document.getElementById('limits');
414
788
  var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
415
789
 
416
790
  function ago(ms) {
@@ -472,6 +846,15 @@ const SCRIPT = `
472
846
  if (body.trim() === '') throw new Error('The server answered with an empty page.');
473
847
  if (!mineStill()) return;
474
848
  live.innerHTML = body;
849
+ // The account's gauges, lifted out of the fragment and into the header where they
850
+ // belong. Here rather than in the fragment's own place on the page because a limit is
851
+ // the account's and not a session's; here rather than in the shell alone because the
852
+ // NUMBERS arrive with the fleet, and a five-hour window that stopped counting down
853
+ // would be the one thing on this page still claiming to be about now.
854
+ // Inside the accepted-answer branch on purpose: a body that was refused is a body
855
+ // nothing is read out of, the account's numbers included.
856
+ var src = document.getElementById('limits-src');
857
+ if (src) limits.innerHTML = src.innerHTML;
475
858
  last = Date.now();
476
859
  failing = false;
477
860
  });
@@ -490,11 +873,340 @@ const SCRIPT = `
490
873
  });
491
874
  }
492
875
 
876
+ // ── the day behind the present ──────────────────────────────────────────────────────
877
+ //
878
+ // The record is asked for once, and every drag after that is a lookup in it. The state of
879
+ // the replay lives here rather than in the fragment for the same reason the tabs do: /live
880
+ // is swapped wholesale every five seconds, and the reader's hand is not the server's to move.
881
+
882
+ var replay = document.getElementById('replay'), scrub = document.getElementById('scrub');
883
+ var playBtn = document.getElementById('play'), covers = document.getElementById('covers');
884
+ var rview = document.getElementById('replay-view'), rmap = document.getElementById('replay-map');
885
+ var rmeta = document.getElementById('replay-meta'), note = document.getElementById('replaying');
886
+ var rlimits = document.getElementById('replay-limits');
887
+ var atEl = document.getElementById('replay-at'), toLive = document.getElementById('to-live');
888
+ var record = null, recordAt = 0, at = -1, replaying = false, playing = null, hgen = 0;
889
+
890
+ // The vocabulary and the geometry, handed over rather than written twice: three words for
891
+ // the three kinds of missing, three glyphs for the three states, one dial radius.
892
+ var WHY = ${JSON.stringify(CTX_WHY)}, SHAPE = ${JSON.stringify(SHAPE)};
893
+ var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
894
+ var ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
895
+ var R = ${DIAL_R}, C = 2 * Math.PI * R;
896
+ var STEP = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
897
+ ? ${PLAY_STEP_CALM_MS} : ${PLAY_STEP_MS};
898
+
899
+ function esc(v) {
900
+ if (v === null || v === undefined || v === '') return '<span class="dim">—</span>';
901
+ return String(v).replace(/[&<>"']/g, function (c) { return ENT[c]; });
902
+ }
903
+
904
+ /** A lookup that cannot be answered by Object.prototype. */
905
+ function own(table, key) {
906
+ return Object.prototype.hasOwnProperty.call(table, key);
907
+ }
908
+
909
+ function two(n) { return (n < 10 ? '0' : '') + n; }
910
+ function hhmm(t) { var d = new Date(t); return two(d.getHours()) + ':' + two(d.getMinutes()); }
911
+
912
+ // A day-long ring can straddle one midnight, and "09:14 – 08:59" reads as a span running
913
+ // backwards until the older edge says which day it is.
914
+ function edge(t, ref) {
915
+ return hhmm(t) + (new Date(t).getDate() === new Date(ref).getDate() ? '' : ' yesterday');
916
+ }
917
+
918
+ // What the range covers, in the record's own terms. Never "a day": that is the size of the
919
+ // ring, and a serve ten minutes old has seen ten minutes.
920
+ function coversText() {
921
+ var n = record.samples.length;
922
+ if (n === 0) {
923
+ // A record empty because every reading FAILED is not a record that has just started, and
924
+ // this was the one branch that threw that away: ten hours of a collector that could not
925
+ // run read exactly like a serve thirty seconds old.
926
+ return record.missed
927
+ ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' and '
928
+ + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
929
+ : 'Nothing recorded yet — this serve started at ' + hhmm(record.since)
930
+ + ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
931
+ }
932
+ var last = record.samples[n - 1].t;
933
+ return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last)
934
+ // Not "when the page loaded": the record is asked for again when a tab that has been
935
+ // away comes back, so the sentence names the last time this page asked rather than a
936
+ // moment it may be hours past.
937
+ + ', as this page last had it — ' + n + ' reading' + (n === 1 ? '' : 's')
938
+ // A gap that says it is a gap is not a gap. The handle steps through readings, not
939
+ // through minutes, and a record with holes in it is not a smooth walk.
940
+ + (record.missed ? ', ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
941
+ + '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.';
942
+ }
943
+
944
+ function ready() {
945
+ var n = record.samples.length;
946
+ replay.hidden = false;
947
+ covers.textContent = coversText();
948
+ scrub.max = String(n === 0 ? 0 : n - 1);
949
+ scrub.disabled = n === 0;
950
+ playBtn.disabled = n === 0;
951
+ }
952
+
953
+ // Revealed, not hidden, when the record cannot be had: a scrubber that silently never
954
+ // appears is indistinguishable from one this build does not have.
955
+ function noRecord(said) {
956
+ replay.hidden = false;
957
+ scrub.disabled = true;
958
+ playBtn.disabled = true;
959
+ covers.textContent = said;
960
+ }
961
+
962
+ function load() {
963
+ // The same generation guard the fleet poll carries, for the same reason and one more.
964
+ // The replaying flag is read when the tab regains focus; the answer lands later, and
965
+ // a reader's hand can arrive in between — so the question is asked AGAIN at the moment of
966
+ // the swap. Without it the record was replaced under a live scrub: the handle pointing at
967
+ // one minute, the map drawing another, out of a record that no longer existed.
968
+ var mine = ++hgen;
969
+ return fetch('/api/history', { cache: 'no-store' }).then(function (res) {
970
+ // The same refusal the fragment makes, for the same reason: what comes back is parsed
971
+ // and drawn into this page, and loopback proves where bytes came from, not who wrote them.
972
+ if (!res.headers.get('X-Tarmac')) throw new Error('The answer on this port did not come from tarmac.');
973
+ return res.text().then(function (body) {
974
+ if (!res.ok) throw new Error(body.split('\\n').filter(Boolean).join(' ').slice(0, 200));
975
+ var got = JSON.parse(body);
976
+ if (!got || !got.samples) throw new Error('the record came back in a shape this page does not know');
977
+ if (mine !== hgen || replaying) return;
978
+ record = got;
979
+ recordAt = Date.now();
980
+ ready();
981
+ });
982
+ }).catch(function (e) {
983
+ if (mine !== hgen) return;
984
+ // A refresh is not a first load. Failing one is no reason to take away a record the page
985
+ // is already holding — and saying "the record could not be read" over one the reader is
986
+ // scrubbing would be false. The fleet poll's own banner already says the server is quiet.
987
+ if (record !== null) return;
988
+ noRecord('The record could not be read — ' + String((e && e.message) || e).slice(0, 200));
989
+ });
990
+ }
991
+
992
+ // One node, out of what the ring holds and nothing more. No name, for any kind of session:
993
+ // a background session is named after the prompt it was given, and the ring stores none.
994
+ function nodeOf(x, anchored) {
995
+ // The map's own rule, in the map's own words: an absent kind is not evidence of an agent,
996
+ // and a fleet where nothing calls itself interactive is a fleet whose source moved.
997
+ var role = !anchored || x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? 'session' : 'agent';
998
+ // Own keys only. A bare read inherits from Object.prototype, so "constructor" and
999
+ // "toString" passed this guard and reached the markup below — into an attribute
1000
+ // unescaped, and into the glyph slot as a function body.
1001
+ var state = own(SHAPE, x.state) ? x.state : 'unknown';
1002
+ var pct = typeof x.ctxPct === 'number' ? x.ctxPct : null;
1003
+ // The ring keeps each reading and never how old that reading was, so the arc weight that
1004
+ // says how much a reading may be believed cannot be earned here. It is not the live
1005
+ // default either: this third value is de-weighted in the stylesheet, and never the warning
1006
+ // hue, which would claim the opposite — that the reading is known to be old.
1007
+ return '<article class="node" data-role="' + role + '" data-state="' + state + '" data-reading="undatable">'
1008
+ // No halo, ever. It means a frame landed moments ago, which is never true of a sample.
1009
+ + '<div class="dial"><svg viewBox="0 0 80 80" aria-hidden="true">'
1010
+ + '<circle class="track' + (pct === null ? ' unmeasured' : '') + '" cx="40" cy="40" r="' + R + '"/>'
1011
+ + (pct === null ? '' : arcOf(pct))
1012
+ + '</svg><div class="val">'
1013
+ + (pct === null
1014
+ ? '<span class="why"><b>—</b>' + esc(own(WHY, x.ctxState) ? WHY[x.ctxState] : 'no reading') + '</span>'
1015
+ : '<span class="pct">' + pct + '<i>%</i></span>')
1016
+ + '</div></div>'
1017
+ + '<div class="who"><span class="shape" aria-hidden="true">' + SHAPE[state] + '</span>'
1018
+ + '<span class="sr">' + state + '</span>'
1019
+ + '<span class="project">' + esc(x.project) + '</span></div>'
1020
+ // The one caption the ring can fill. Guarded on the state as well as on the field: a
1021
+ // reason left over beside another state is not a session waiting for anything, and esc()
1022
+ // answers an absent field with a dash, which would caption a node "waiting for —".
1023
+ + (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
1024
+ + (x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? '' : '<div class="sub">' + esc(x.kind) + '</div>')
1025
+ + (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
1026
+ + '</article>';
1027
+ }
1028
+
1029
+ // The server's own arithmetic, off the server's own radius: a fraction of the real
1030
+ // circumference, never pathLength, so a browser that ignores it cannot close every ring
1031
+ // into a full context window.
1032
+ function arcOf(pct) {
1033
+ var filled = (Math.min(100, Math.max(0, pct)) / 100) * C;
1034
+ var r2 = function (n) { return Math.round(n * 100) / 100; };
1035
+ return '<circle class="arc" cx="40" cy="40" r="' + R + '" transform="rotate(-90 40 40)"'
1036
+ + ' stroke-dasharray="' + r2(filled) + ' ' + r2(C - filled) + '"/>';
1037
+ }
1038
+
1039
+ // ── the account, as it stood that minute ────────────────────────────────────────────
1040
+ //
1041
+ // The second thing this page interprets twice, and for the same reason as the dials: a
1042
+ // replay is a lookup in samples the page already holds, and the ring holds the payload's own
1043
+ // rate_limits rather than anything rendered. The vocabulary, the dash and the two windows are
1044
+ // handed over below rather than written again; what is mirrored is the arithmetic, and a
1045
+ // test compares this output with the server's character for character.
1046
+ //
1047
+ // What it counts the reset against is the SAMPLE's own clock, never Date.now(). A reset is a
1048
+ // moment, and "how long is left" is a question about the minute being replayed: at 09:14 the
1049
+ // five-hour window had two hours to run, and it had two hours to run whatever time it is now.
1050
+ // Counted against the present, every reset in the record would read as long overdue the
1051
+ // moment it aged past — a page announcing an account over its limit for a day that ended.
1052
+ var LIMITS = ${JSON.stringify(LIMIT_WINDOWS)}, LIMIT_WHY = ${JSON.stringify(LIMIT_WHY)};
1053
+ var DASH = ${JSON.stringify(dash())};
1054
+
1055
+ function left(ms) {
1056
+ var m = Math.floor(ms / 60000);
1057
+ if (m < 1) return '<1m';
1058
+ if (m < 60) return m + 'm';
1059
+ var h = Math.floor(m / 60);
1060
+ if (h < 24) return m % 60 === 0 ? h + 'h' : h + 'h ' + (m % 60) + 'm';
1061
+ var d = Math.floor(h / 24);
1062
+ return h % 24 === 0 ? d + 'd' : d + 'd ' + (h % 24) + 'h';
1063
+ }
1064
+
1065
+ function gaugesOf(rl, now) {
1066
+ // Anything can be in a sample: rate_limits is a shape someone else versions, and the ring
1067
+ // stored whatever the payload had. None of it may throw in the header of a dashboard.
1068
+ var ok = rl !== null && rl !== undefined && typeof rl === 'object' && !Array.isArray(rl);
1069
+ var html = '';
1070
+ for (var i = 0; i < LIMITS.length; i++) {
1071
+ var w = ok ? rl[LIMITS[i].key] : undefined;
1072
+ var has = w !== null && w !== undefined && typeof w === 'object' && !Array.isArray(w) && 'used_percentage' in w;
1073
+ var v = has ? w.used_percentage : undefined;
1074
+ var pct = has && typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? Math.floor(v) : null;
1075
+ var at = has && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;
1076
+ var ms = at === null ? null : at * 1000 - now;
1077
+ // The server's horizon, off the server's own number: a reset further from the reading than
1078
+ // the longest window can be is not this account's reset, whatever it says.
1079
+ if (ms !== null && Math.abs(ms) > ${RESET_HORIZON_MS}) ms = null;
1080
+ // Presence, never value: a window that is there and null is a number not taken yet, and
1081
+ // one that is gone is a schema that moved. Same discriminant as everywhere else here.
1082
+ // Read off rl and NOT off ok: rate_limits carrying something that is not a pair of
1083
+ // windows — an array, which the snapshot reader lets through — is a schema that moved,
1084
+ // not an account nobody measured. Written as !ok, this said the opposite of the server
1085
+ // about the very same minute.
1086
+ var why = pct !== null ? null : (rl === null || rl === undefined || (has && v === null)) ? 'absent' : 'drift';
1087
+ html += '<div class="gauge"><span class="lbl" aria-hidden="true">' + LIMITS[i].label + '</span>'
1088
+ + '<span class="sr">' + LIMITS[i].said + '</span>'
1089
+ + (pct === null
1090
+ ? '<span class="rail unmeasured" aria-hidden="true"></span>'
1091
+ : '<span class="rail" aria-hidden="true"><i style="width:' + pct + '%"></i></span>')
1092
+ + '<span class="num">' + (pct === null ? DASH : pct + '%') + '</span>'
1093
+ + '<span class="reset">'
1094
+ + (pct === null
1095
+ ? LIMIT_WHY[why]
1096
+ : ms === null
1097
+ ? 'reset ' + DASH
1098
+ : ms > 0 ? 'resets in ' + left(ms) : 'reset was due ' + left(-ms) + ' ago')
1099
+ + '</span></div>';
1100
+ }
1101
+ return html;
1102
+ }
1103
+
1104
+ function nodesOf(s) {
1105
+ var anchored = false, html = '', i;
1106
+ for (i = 0; i < s.sessions.length; i++) if (s.sessions[i].kind === INTERACTIVE) anchored = true;
1107
+ // In the order the sample carries. The live map places an agent beside the session it
1108
+ // shares a directory with; the ring holds no directory, so the past is drawn in the order
1109
+ // the fleet was sorted in rather than in a grouping this page would have to invent.
1110
+ for (i = 0; i < s.sessions.length; i++) html += nodeOf(s.sessions[i], anchored);
1111
+ return html;
1112
+ }
1113
+
1114
+ // The fleet of that minute, counted from that minute. A partial sum is never presented as
1115
+ // the total, the same rule the live header follows.
1116
+ function metaOf(s) {
1117
+ var n = s.sessions.length, busy = 0, cost = 0, reporting = 0;
1118
+ for (var i = 0; i < n; i++) {
1119
+ if (s.sessions[i].state === 'busy') busy++;
1120
+ if (typeof s.sessions[i].costUsd === 'number') { cost += s.sessions[i].costUsd; reporting++; }
1121
+ }
1122
+ return n + ' session' + (n === 1 ? '' : 's') + ' · ' + busy + ' busy · '
1123
+ + (reporting === 0 ? 'cost —'
1124
+ : '$' + cost.toFixed(2) + (reporting < n ? ' (' + reporting + '/' + n + ' reporting cost)' : ''));
1125
+ }
1126
+
1127
+ function draw(i) {
1128
+ var s = record && record.samples[i];
1129
+ if (!s) return;
1130
+ at = i;
1131
+ replaying = true;
1132
+ scrub.value = String(i);
1133
+ // The handle's own value is an index, so a reader who cannot see the banner would be read
1134
+ // "3" while the fleet on screen is three hours old. The minute travels with the handle.
1135
+ scrub.setAttribute('aria-valuetext', hhmm(s.t));
1136
+ atEl.textContent = hhmm(s.t);
1137
+ rmeta.textContent = metaOf(s);
1138
+ rmap.innerHTML = nodesOf(s);
1139
+ // The account of that minute, in the place the live pair occupies — which the body class
1140
+ // has just taken down. One allowance on screen at a time, and it is the one belonging to
1141
+ // the fleet being shown.
1142
+ rlimits.innerHTML = gaugesOf(s.rateLimits, s.t);
1143
+ rlimits.hidden = false;
1144
+ note.hidden = false;
1145
+ rview.hidden = false;
1146
+ document.body.classList.toggle('replaying', true);
1147
+ }
1148
+
1149
+ function stopPlay() {
1150
+ if (playing) { clearInterval(playing); playing = null; }
1151
+ playBtn.textContent = 'Play';
1152
+ }
1153
+
1154
+ // Back to now, in one gesture, with nothing of the past left behind a hidden attribute.
1155
+ // The position is NOT reset: the handle stays where the reader let go of it, so what it
1156
+ // shows and where play would pick up are the same place.
1157
+ function present() {
1158
+ stopPlay();
1159
+ replaying = false;
1160
+ note.hidden = true;
1161
+ rview.hidden = true;
1162
+ rlimits.hidden = true;
1163
+ rlimits.innerHTML = '';
1164
+ rmap.innerHTML = '';
1165
+ scrub.removeAttribute('aria-valuetext');
1166
+ document.body.classList.toggle('replaying', false);
1167
+ }
1168
+
1169
+ function play() {
1170
+ if (playing) { stopPlay(); return; }
1171
+ if (!record || record.samples.length === 0) return;
1172
+ // From the top when there is nothing to resume: a play button that ends where it started
1173
+ // has played nothing.
1174
+ draw(at < 0 || at >= record.samples.length - 1 ? 0 : at);
1175
+ playBtn.textContent = 'Pause';
1176
+ playing = setInterval(function () {
1177
+ // It stops at the end rather than looping back: a day that restarts on its own is a
1178
+ // day whose beginning and end are impossible to tell apart.
1179
+ if (at >= record.samples.length - 1) { stopPlay(); return; }
1180
+ draw(at + 1);
1181
+ }, STEP);
1182
+ }
1183
+
1184
+ scrub.addEventListener('input', function () { stopPlay(); draw(Number(scrub.value)); });
1185
+ playBtn.addEventListener('click', play);
1186
+ toLive.addEventListener('click', present);
1187
+
493
1188
  setInterval(tick, 1000);
494
1189
  setInterval(function () { if (!document.hidden) poll(); }, ${REFRESH_MS});
495
- document.addEventListener('visibilitychange', function () { if (!document.hidden) poll(); });
1190
+ document.addEventListener('visibilitychange', function () {
1191
+ if (document.hidden) return;
1192
+ poll();
1193
+ // The record was answered once, at load. A tab left alone all afternoon holds a record
1194
+ // that stops where the reader's attention did — so it is asked again on the way back in.
1195
+ // Never while a reader is scrubbing (the record under their hand is not ours to swap, and
1196
+ // the load asks that question again when the answer lands), and never for one younger than
1197
+ // a single slot. A record that could never be read at all is not retried here: it stays
1198
+ // null, the guard holds, and the reader has a sentence saying so rather than a page
1199
+ // quietly trying again forever.
1200
+ if (!replaying && record !== null && Date.now() - recordAt >= record.cadence) load();
1201
+ });
1202
+ // Only where there is a scrubber to feed. The table view hides these controls in CSS, and a
1203
+ // full ring is megabytes of session ids, projects and costs: fetching and parsing it to
1204
+ // write a sentence into an element with display:none is a cost paid on every load of the
1205
+ // page most people open first, for a control they cannot see.
1206
+ if (${view === 'map'}) load();
496
1207
  })();
497
1208
  `;
1209
+ }
498
1210
  /**
499
1211
  * The sort puts busy first, unknown next, idle last. This is where that order is given its
500
1212
  * weight — an accent down the row and a bold name for the ones that are working, a quiet row
@@ -506,7 +1218,7 @@ const SCRIPT = `
506
1218
  */
507
1219
  function renderRow(r) {
508
1220
  const state = stateOf(r);
509
- const word = r.busy === true ? 'busy' : r.busy === false ? 'idle' : (r.status ?? 'unknown');
1221
+ const word = stateLabel(state, r);
510
1222
  // `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
511
1223
  // and a value whose column has no name is a bare "—" that could mean anything.
512
1224
  // Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
@@ -523,12 +1235,118 @@ function renderRow(r) {
523
1235
  <td data-label="Uptime" class="num dim"><span class="v">${r.uptimeMs === null ? dash() : esc(duration(r.uptimeMs))}</span></td>
524
1236
  </tr>`;
525
1237
  }
526
- const stateOf = (r) => (r.busy === true ? 'busy' : r.busy === false ? 'idle' : 'unknown');
527
- const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
1238
+ const SHAPE = { busy: '●', waiting: '', unknown: '', idle: '' };
1239
+ /**
1240
+ * The state in words, for both surfaces — derived from the state the MODEL decided, never
1241
+ * from the row a second time. Two expressions for one fact on one element is how a node ends
1242
+ * up shaped `unknown` and captioned `idle`.
1243
+ *
1244
+ * An unrecognised status is quoted as it came rather than flattened to "unknown": the point
1245
+ * of keeping it is that someone reading the page can go and find out what `compacting` means.
1246
+ */
1247
+ const stateWord = (state, r) => state === 'unknown' ? (r.status ?? 'unknown') : state;
1248
+ /**
1249
+ * The same word with the reason attached, for the table — which has one cell per session and
1250
+ * no room for a caption of its own. The map keeps them apart instead: the word is what a
1251
+ * screen reader is handed in place of the glyph, and repeating the reason there would read it
1252
+ * twice, once hidden and once out of the caption below it.
1253
+ */
1254
+ const stateLabel = (state, r) => state === 'waiting' && r.waitingFor ? `${stateWord(state, r)} · ${r.waitingFor}` : stateWord(state, r);
1255
+ /**
1256
+ * Which kind of missing a missing percentage is. One lookup for both surfaces: the table
1257
+ * says it beside a dash, the map says it inside an empty dial, and a second copy of these
1258
+ * three words is a second chance to describe the same state differently.
1259
+ */
1260
+ const CTX_WHY = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' };
1261
+ /**
1262
+ * The map: one node per session, laid out as a grid rather than a graph. An empty fleet is not
1263
+ * its business — `renderLive` says that once, above both views, rather than letting each of
1264
+ * them render the same sentence and hide one of the two.
1265
+ *
1266
+ * There are no edges because the sources publish no relationship between two sessions — the
1267
+ * one thing they do carry is the working directory, and that is expressed by putting an
1268
+ * agent NEXT to the session it shares a directory with, never by drawing a line that would
1269
+ * claim more than the data says.
1270
+ *
1271
+ * Everything a reader interprets is decided in `map.ts` and rendered here, on the server,
1272
+ * for the same reason the table is: the rules that keep a reading honest are tested, and a
1273
+ * copy of them re-derived in browser JavaScript would sit where this suite cannot reach.
1274
+ */
1275
+ export function renderMap(fleet) {
1276
+ return `<div class="map">${buildMap(fleet).nodes.map(renderNode).join('')}</div>`;
1277
+ }
1278
+ /**
1279
+ * One node. Five facts, in five channels that do not depend on colour alone: the arc is how
1280
+ * full the context is, the dial's weight is how much that reading may be believed, a dotted
1281
+ * dial is no reading at all, the shape beside the name is the session's state, and the halo
1282
+ * says one landed moments ago. The words under them are the same ones the table uses for the
1283
+ * same conditions — including the halo's, which would otherwise live only in a drawing.
1284
+ *
1285
+ * One state brings a caption with it. A waiting session is the only one where the shape
1286
+ * leaves a question the source can answer — which human answer it is halted on — and it is
1287
+ * printed directly under the name, not hidden in a title attribute nobody hovers on a phone.
1288
+ */
1289
+ function renderNode({ row: r, role, state, reading, measured, pulse }) {
1290
+ // The model owns "is there a number"; this reads its verdict rather than asking the row a
1291
+ // second question. `fresh` and `drift` are the two states where the age of the file and the
1292
+ // presence of a reading disagree, and they are the two that matter most.
1293
+ const pct = measured ? r.ctxPct : null;
1294
+ const value = pct === null
1295
+ ? `<span class="why"><b>&mdash;</b>${esc(CTX_WHY[r.ctxState] ?? 'no reading')}</span>`
1296
+ : `<span class="pct">${pct}<i>%</i></span>`;
1297
+ // The reading's own age, and only when it is one the reader must not take for current.
1298
+ const asOf = reading === 'stale' && r.snapshotAgeMs !== null
1299
+ ? `<div class="asof stale">! ${esc(asOfAge(r.snapshotAgeMs))} ago</div>`
1300
+ : reading === 'undated'
1301
+ ? `<div class="asof stale">! undated</div>`
1302
+ : '';
1303
+ // An agent carries its own project, like every other node. Placement is not a promise —
1304
+ // the grid wraps where the viewport says, and the fleet's sort can hand the same agent a
1305
+ // different neighbour on the next poll — so nothing here points at the node beside it.
1306
+ return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
1307
+ <div class="dial">
1308
+ <svg viewBox="0 0 80 80" aria-hidden="true">${pulse ? `<circle class="halo" cx="40" cy="40" r="${DIAL_R}"/>` : ''}<circle class="track${measured ? '' : ' unmeasured'}" cx="40" cy="40" r="${DIAL_R}"/>${pct === null ? '' : arc(pct)}</svg>
1309
+ <div class="val">${value}</div>
1310
+ ${pulse ? `<span class="sr">a reading just landed</span>` : ''}
1311
+ </div>
1312
+ <div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="project">${esc(r.project)}</span></div>
1313
+ ${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
1314
+ <div class="sub">${esc(r.name)}</div>
1315
+ ${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
1316
+ <div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
1317
+ ${asOf}
1318
+ </article>`;
1319
+ }
1320
+ /**
1321
+ * How old a dated reading is, in the words both surfaces use.
1322
+ *
1323
+ * `duration` floors to whole minutes, and `--stale-after` takes seconds — so a 30s reading
1324
+ * judged against a 2s threshold rendered "! 0m ago": the "!" saying past the threshold and
1325
+ * the "0m" saying brand new, in the same breath. Under a minute the age stops pretending to
1326
+ * be a round number.
1327
+ */
1328
+ const asOfAge = (ms) => (ms < 60_000 ? '<1m' : duration(ms));
1329
+ /** The dial's geometry. One radius, named once, so the arithmetic below cannot drift from it. */
1330
+ const DIAL_R = 30;
1331
+ const DIAL_C = 2 * Math.PI * DIAL_R;
1332
+ /**
1333
+ * The filled part of the ring, as a fraction of the circle's real circumference.
1334
+ *
1335
+ * `pathLength="100"` would say the same thing in far prettier markup — "62 filled, 38 empty"
1336
+ * — but it is an attribute browsers have not always honoured on basic shapes, and the way it
1337
+ * fails is the one this page cannot afford: the dash array is ignored, the arc closes, and
1338
+ * every session reads as a full context window. Two decimals is well under a pixel at this
1339
+ * radius, and it keeps the markup diffable.
1340
+ */
1341
+ function arc(pct) {
1342
+ const filled = (Math.min(100, Math.max(0, pct)) / 100) * DIAL_C;
1343
+ return (`<circle class="arc" cx="40" cy="40" r="${DIAL_R}" transform="rotate(-90 40 40)"` +
1344
+ ` stroke-dasharray="${round2(filled)} ${round2(DIAL_C - filled)}"/>`);
1345
+ }
1346
+ const round2 = (n) => Math.round(n * 100) / 100;
528
1347
  function ctxCell(r) {
529
1348
  if (r.ctxPct === null) {
530
- const why = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' }[r.ctxState] ?? '';
531
- return `${dash()} <span class="dim">${esc(why)}</span>`;
1349
+ return `${dash()} <span class="dim">${esc(CTX_WHY[r.ctxState] ?? '')}</span>`;
532
1350
  }
533
1351
  // A stale reading is still the truth — of an earlier moment. Show it, and date it, with
534
1352
  // the same "!" the terminal marks it with: an age in the same grey as everything else is
@@ -539,7 +1357,7 @@ function ctxCell(r) {
539
1357
  // "! 0m ago" — a missing measurement as a zero, contradicting itself in the same breath
540
1358
  // (the "!" says past the threshold, the "0m" says brand new). The terminal path already
541
1359
  // re-checked it; the two surfaces are not allowed to disagree about the module's own rule.
542
- const asOf = r.stale && r.snapshotAgeMs !== null ? ` <span class="stale">! ${esc(duration(r.snapshotAgeMs))} ago</span>` : '';
1360
+ const asOf = r.stale && r.snapshotAgeMs !== null ? ` <span class="stale">! ${esc(asOfAge(r.snapshotAgeMs))} ago</span>` : '';
543
1361
  return `<span class="bar"><i style="width:${Math.min(100, r.ctxPct)}%"></i></span>${r.ctxPct}%${asOf}`;
544
1362
  }
545
1363
  /** A partial sum is never presented as the fleet's total. */