@adrrr/tarmac 0.3.0 → 0.4.1

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
@@ -9,6 +9,8 @@
9
9
  import { formatDuration } from './config.js';
10
10
  import { buildMap, INTERACTIVE, stateOf } from './map.js';
11
11
  import { schemaNotice } from './schema.js';
12
+ import { LIMIT_WINDOWS, RESET_HORIZON_MS, readLimits } from './limits.js';
13
+ import { accountLimits, busyOnStaleFleet } from './fleet.js';
12
14
  /**
13
15
  * The other thing this module renders: the plan a user consents to before install or
14
16
  * uninstall touches their settings.json. Everything the decision rests on has to be here —
@@ -147,7 +149,7 @@ export function renderTable({ rows, health }) {
147
149
  const head = ['PROJECT', 'STATE', 'CTX', 'AS OF', 'MODEL', 'EFFORT', 'COST', 'UP'];
148
150
  const body = rows.map((r) => [
149
151
  r.project ?? '—',
150
- r.busy === true ? 'busy' : r.busy === false ? 'idle' : `?${r.status ?? ''}`,
152
+ stateCell(r),
151
153
  r.ctxPct === null ? `— ${r.ctxState}` : `${r.ctxPct}%`,
152
154
  // The age of the reading, never implied to be "now".
153
155
  r.snapshotAgeMs === null ? '—' : ahead(r) ? '— ahead' : `${age(r.snapshotAgeMs)}${r.stale ? ' !' : ''}`,
@@ -200,6 +202,21 @@ export function renderTable({ rows, health }) {
200
202
  (warns.length ? '\n' + warns.join('\n') + '\n' : '') +
201
203
  `\n${health.sessions} sessions · ${health.busy} busy · ${total}\n`);
202
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
+ }
203
220
  /**
204
221
  * A snapshot dated AFTER the clock we are reading it with — a mount whose time runs ahead, an
205
222
  * NTP correction between the write and the read. Its age is not a small number, it is not a
@@ -261,16 +278,36 @@ export function renderLive(fleet) {
261
278
  if (health.unknownStatus > 0) {
262
279
  warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
263
280
  }
264
- if (health.stale > 0) {
265
- warnings.push(`${health.stale} reading(s) are older than the ${formatDuration(health.staleAfterMs)} freshness threshold — a statusline is only written when its terminal draws a frame, so an idle session's number is "as of" its last one. Set another with --stale-after.`);
281
+ // NOT "N readings are stale", which used to live here and was on every hour of every day
282
+ // (#53): a statusline is written when a terminal draws a frame, so a fleet that idles keeps
283
+ // yesterday's numbers and says so on every poll. The rows and the nodes date each reading
284
+ // themselves — a page-wide box repeating it is wallpaper, and wallpaper is what teaches a
285
+ // reader to skip the boxes below. What is left here is the one stale-shaped thing that is
286
+ // an event: `busyOnStaleFleet` (see fleet.ts for why both halves of it are needed).
287
+ const stalled = busyOnStaleFleet(rows);
288
+ if (stalled > 0) {
289
+ warnings.push(`Every context reading is stale, including ${stalled} session(s) busy right now — a busy session redraws its status line, so its reading should not be older than ${formatDuration(health.staleAfterMs)}. The statusline writer looks stopped rather than the fleet idle: check that the wrapper is still installed and that the snapshot directory is writable.`);
266
290
  }
267
291
  const skewed = rows.filter(ahead).length;
268
292
  if (skewed > 0) {
269
293
  warnings.push(`${skewed} reading(s) are dated in the future — ${SKEW}. They are shown undated rather than as brand new.`);
270
294
  }
295
+ // Under the fleet, at a footnote's weight: two facts that are true, worth keeping, and worth
296
+ // nobody's alarm. The first is the legend for the marks the rows carry — a `!` whose
297
+ // threshold is invisible is a mark the reader cannot argue with, which is why demoting the
298
+ // banner above could not take the number with it. The second is a maintainer's line: it
299
+ // stands for every user of a released tarmac until the next release ships the fixture, so
300
+ // amber would mean amber forever. Both keep every word they had.
301
+ const notes = [];
302
+ // Not under the stall banner, which names the same threshold two lines up: the pair reads as
303
+ // the alarm followed by its own excuse, and the excuse is the reading the alarm exists to
304
+ // tell you not to accept.
305
+ if (health.stale > 0 && stalled === 0) {
306
+ notes.push(`Readings past the ${formatDuration(health.staleAfterMs)} freshness threshold are dated where they sit — a statusline is only written when its terminal draws a frame, so an idle session's number is "as of" its last one. Set another with --stale-after.`);
307
+ }
271
308
  const schema = schemaNotice(health.schemaGuard);
272
309
  if (schema)
273
- warnings.push(schema);
310
+ notes.push(schema);
274
311
  // Both views, every time, out of the one reading the page just asked for. The tabs are
275
312
  // links and the shell decides which of the two is visible, so a fleet cannot be drawn as a
276
313
  // table of one age beside a map of another.
@@ -280,16 +317,98 @@ export function renderLive(fleet) {
280
317
  // on screen and read out all the same by anything going through the markup.
281
318
  const body = rows.length === 0
282
319
  ? empty(health)
283
- : `<div class="view view-table"><div class="wrap"><table>
320
+ : // `aria-describedby` on both views, because demoting the footnote moved it BELOW every
321
+ // row and every node: a reader going through the markup now meets `! 3h ago` N times
322
+ // before anything says what threshold put it there. Sighted readers glance down; this
323
+ // is the same glance for anyone who cannot. The target is rendered whether or not it
324
+ // has anything in it, so the reference is never dangling.
325
+ `<div class="view view-table"><div class="wrap"><table aria-describedby="fleet-notes">
284
326
  <thead><tr>
285
327
  <th>Project</th><th>Session</th><th>State</th><th>Context</th><th>Model</th><th>Effort</th><th>Cost</th><th>Uptime</th>
286
328
  </tr></thead>
287
329
  <tbody>${rows.map(renderRow).join('')}</tbody>
288
330
  </table></div></div>
289
- <div class="view view-map">${renderMap(fleet)}</div>`;
290
- return `<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
331
+ <div class="view view-map" role="group" aria-label="fleet map" aria-describedby="fleet-notes">${renderMap(fleet)}</div>`;
332
+ return `<div id="limits-src" hidden>${renderLimits(fleet)}</div>
333
+ <div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
291
334
  ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
292
- ${body}`;
335
+ ${body}
336
+ <div id="fleet-notes">${notes.map((n) => `<div class="note">${esc(n)}</div>`).join('')}</div>`;
337
+ }
338
+ /**
339
+ * The account's two windows, for the page's header.
340
+ *
341
+ * They are the one pair of numbers here that is not about a session: every session on the page
342
+ * spends from the same five-hour and seven-day allowance, so the gauges sit at the top of the
343
+ * page rather than on a node — and the fleet's own rule decides whose reading counts when the
344
+ * sessions carry the same number at different ages.
345
+ *
346
+ * Rendered into the FRAGMENT as well as into the shell, in a slot the script copies up on every
347
+ * swap. The header is the shell's — it has to survive a poll, the tabs and a replay — but the
348
+ * numbers are the fleet's, and the fleet is what the fragment carries. A gauge left in the shell
349
+ * alone would be as old as the tab.
350
+ */
351
+ export function renderLimits({ rows, health }) {
352
+ const account = accountLimits(rows);
353
+ const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt)
354
+ .map(gauge)
355
+ .join('');
356
+ // Dated when the snapshot behind it is past the threshold, exactly as the table dates a stale
357
+ // context. It matters more here than anywhere else on the page: the percentage is as old as
358
+ // that snapshot, while the countdown beside it is recomputed on every five-second re-render —
359
+ // so an undated pair puts a frozen number next to a visibly moving one and lets the reader
360
+ // assume both are now.
361
+ //
362
+ // Once for the two, not once each: both windows come out of the SAME snapshot, and the same
363
+ // fact said twice is noise. The replay has no equivalent — the ring keeps each reading and
364
+ // never how old it was, which is why nothing replayed on this page is dated.
365
+ const stale = account !== null && account.ageMs > health.staleAfterMs;
366
+ return gauges + (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '');
367
+ }
368
+ /**
369
+ * One window. Four things in a line: which window it is, a bar for the glance, the number that
370
+ * is authoritative, and how long is left. The bar is `aria-hidden` because it says nothing the
371
+ * number does not, and the abbreviation is replaced rather than doubled for a reader who hears
372
+ * the page — "5h" is a label on a screen and a syllable in an ear.
373
+ */
374
+ function gauge(g) {
375
+ // No fill, ever, for a window nobody read: an empty bar is what an account at 0% wears, and
376
+ // "I could not look" must not be able to wear it. The same dotted emptiness as an unmeasured
377
+ // dial, in the shape a bar has.
378
+ const rail = g.pct === null
379
+ ? `<span class="rail unmeasured" aria-hidden="true"></span>`
380
+ : `<span class="rail" aria-hidden="true"><i style="width:${g.pct}%"></i></span>`;
381
+ return (`<div class="gauge"><span class="lbl" aria-hidden="true">${g.label}</span><span class="sr">${g.said}</span>` +
382
+ `${rail}<span class="num">${g.pct === null ? dash() : `${g.pct}%`}</span>` +
383
+ `<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs)}</span></div>`);
384
+ }
385
+ /** Which kind of missing a missing window is, in the two words both surfaces use. */
386
+ const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
387
+ /**
388
+ * The reset, as a stretch of time rather than as the epoch the payload carries.
389
+ *
390
+ * A negative one is not a countdown to be printed with a minus sign: the window rolled over
391
+ * after the reading that reported it, so the percentage beside these words belongs to a window
392
+ * that no longer exists. Saying that is the whole point of showing a reset at all.
393
+ */
394
+ const resetWords = (ms) => ms === null ? `reset ${dash()}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
395
+ /**
396
+ * How long, in the two units that matter at each scale. Deliberately finer than `duration()`
397
+ * next door, which floors a session's uptime to whole hours: five hours is a window someone
398
+ * plans the next hour around, and "resets in 2h" said anywhere between 2h00 and 2h59 is the
399
+ * kind of rounding that makes a reader stop believing the number.
400
+ */
401
+ function left(ms) {
402
+ const m = Math.floor(ms / 60000);
403
+ if (m < 1)
404
+ return '<1m';
405
+ if (m < 60)
406
+ return `${m}m`;
407
+ const h = Math.floor(m / 60);
408
+ if (h < 24)
409
+ return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
410
+ const d = Math.floor(h / 24);
411
+ return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
293
412
  }
294
413
  /**
295
414
  * What to say instead of a fleet. Discovery returning entries we could not identify is not
@@ -346,14 +465,20 @@ function ago(ms) {
346
465
  return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
347
466
  }
348
467
  export function renderPage(fleet, view = 'table') {
468
+ // The header's copy. `renderLive` below renders its own, out of this same fleet and through
469
+ // this same function — two calls of one pure renderer over one reading, which is what keeps
470
+ // the pair the reader sees and the pair the script will copy up from being two accounts.
471
+ const gauges = renderLimits(fleet);
349
472
  return `<!doctype html>
350
473
  <html lang="en"><head>
351
474
  <meta charset="utf-8">
352
475
  <meta name="viewport" content="width=device-width,initial-scale=1">
353
476
  <title>tarmac — fleet</title>
354
477
  <style>
355
- :root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; }
356
- @media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; } }
478
+ /* --wait is a fourth hue rather than the warning one: a session blocked on a human is not
479
+ a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
480
+ :root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8; }
481
+ @media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; --wait:#93c5fd; } }
357
482
  body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
358
483
  font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
359
484
  header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
@@ -364,6 +489,11 @@ export function renderPage(fleet, view = 'table') {
364
489
  .warn { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:6px;
365
490
  padding:.35rem .65rem; margin:.3rem 0; font-size:.8rem; line-height:1.45; }
366
491
  .warn:last-of-type { margin-bottom:.9rem; }
492
+ /* The footnote: same words, none of the weight. Dim, small, below the fleet and with no box
493
+ around it, because what it carries is true rather than urgent — the threshold that dated a
494
+ reading, the payload shapes nobody has captured yet. It reads as chrome to someone
495
+ scanning their sessions and as an answer to someone who came looking for it. */
496
+ .note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:.9rem 0 0; max-width:95ch; }
367
497
  .stale { color:var(--warn); font-weight:600; }
368
498
  .wrap { overflow-x:auto; }
369
499
  table { border-collapse:collapse; width:100%; min-width:44rem; }
@@ -377,11 +507,14 @@ export function renderPage(fleet, view = 'table') {
377
507
  .pill { display:inline-block; font-size:.8rem; font-weight:600; padding:.05rem .5rem;
378
508
  border:1px solid currentColor; border-radius:99px; white-space:nowrap; }
379
509
  .pill.busy { color:var(--busy); }
510
+ .pill.waiting { color:var(--wait); }
380
511
  .pill.unknown { color:var(--warn); }
381
512
  .pill.idle { color:var(--dim); font-weight:400; }
382
- /* The weight the sort deserves: busy rows carry an accent and a bold name. */
513
+ /* Accented states carry their own hue down the row edge. Bold stays on busy alone — it
514
+ says "working", not "read me first"; waiting's weight is the top of the sort. */
383
515
  td:first-child { border-left:3px solid transparent; }
384
516
  tr[data-state="busy"] td:first-child { border-left-color:var(--busy); }
517
+ tr[data-state="waiting"] td:first-child { border-left-color:var(--wait); }
385
518
  tr[data-state="unknown"] td:first-child { border-left-color:var(--warn); }
386
519
  tr[data-state="busy"] .project { font-weight:700; }
387
520
  /* The bar reads a magnitude at a glance; the number beside it is what is authoritative.
@@ -405,6 +538,72 @@ export function renderPage(fleet, view = 'table') {
405
538
  body[data-view="table"] .view-map { display:none; }
406
539
  body[data-view="map"] .view-table { display:none; }
407
540
 
541
+ /* ── the account's two windows ───────────────────────────────────────────────────────
542
+ In the header, because a rate limit is the account's and not a node's. Slim on purpose:
543
+ the fleet is what the page is about, and these two numbers are the weather it flies in.
544
+ Laid out with flex behind the same :not([hidden]) guard the replay containers carry —
545
+ the replayed pair ships hidden, and a display in a stylesheet beats the attribute. */
546
+ .limits:not([hidden]) { display:flex; gap:1rem; flex-wrap:wrap; align-items:center; }
547
+ .gauge { display:flex; align-items:baseline; gap:.35rem; font-size:.8rem; }
548
+ /* Not upper-cased, alone among the small labels on this page: "5H" is not an hour, and a
549
+ unit that has been shouted reads as a different unit. */
550
+ .gauge .lbl { color:var(--dim); font-weight:600; letter-spacing:.04em; }
551
+ .gauge .num { font-variant-numeric:tabular-nums; font-weight:650; }
552
+ .gauge .reset { color:var(--dim); }
553
+ /* Same bargain as the row bars: a glance at a magnitude, in the quiet ink of a secondary
554
+ fact, beside the number that is the authority. Its own class rather than .bar — that one
555
+ is dropped below 46rem, where a card layout gives every value the name of its column, and
556
+ these two have no column to be named by. */
557
+ .gauge .rail { display:inline-block; width:3.5rem; height:.3rem; border-radius:99px;
558
+ background:var(--line); align-self:center; }
559
+ .gauge .rail > i { display:block; height:100%; border-radius:99px; background:var(--dim); }
560
+ /* Nothing was measured — the dotted track of an unmeasured dial, in the shape of a bar. An
561
+ empty rail is what an account at 0% wears, and the two must not match. */
562
+ .gauge .rail.unmeasured { background:repeating-linear-gradient(90deg,var(--line) 0 2px,transparent 2px 8px); }
563
+ /* The live pair goes down with the live fragment: they are about now, and left up they would
564
+ be the one present-tense number standing over a fleet three hours old. */
565
+ body.replaying #limits { display:none; }
566
+ /* The replayed pair leads the past fleet rather than sitting on top of its totals. */
567
+ #replay-limits { margin-bottom:.2rem; }
568
+
569
+ /* ── the scrubber ────────────────────────────────────────────────────────────────────
570
+ Under the map, and only under the map: the record holds what the MAP draws, so a
571
+ scrubber over the table would offer a drag onto rows it cannot fill.
572
+ Everything here lives in the shell for the same reason the tabs do: the /live fragment is
573
+ swapped into innerHTML every five seconds, and a handle inside it would be dragged back
574
+ to the present by a poll nobody asked for. */
575
+ body[data-view="table"] #replay, body[data-view="table"] #replay-view { display:none; }
576
+ /* One fleet at a time, and the whole fragment rather than only its map: the fragment's
577
+ header is the LIVE count, cost and timestamp, and hiding the map alone left it sitting
578
+ directly above the replayed one — two totals of two different moments, the pair dated
579
+ with the present. The warnings above them are about the present too. The failure banner
580
+ is in the shell, so a refresh that breaks mid-replay still says so. */
581
+ body.replaying #live { display:none; }
582
+ /* Both of these are laid out with flex, and both are hidden by the attribute until a script
583
+ raises them — so the display is refused to a hidden one explicitly. The hidden attribute
584
+ is only a UA rule of display:none, and any display a stylesheet gives the same element
585
+ beats it: unguarded, this page came up announcing a replay nobody had asked for. */
586
+ .replay:not([hidden]) { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-top:1rem;
587
+ padding-top:.7rem; border-top:1px solid var(--line); }
588
+ .replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
589
+ border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
590
+ .replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
591
+ .replay input[type="range"]:disabled { opacity:.4; }
592
+ /* The two things the reader has to be able to read while dragging: the minute under the
593
+ handle, and what the whole range covers. Tabular, so neither jitters as it counts. */
594
+ #replay-at { font-variant-numeric:tabular-nums; font-weight:600; }
595
+ .replay .covers { flex-basis:100%; color:var(--dim); font-size:.75rem; }
596
+ /* The banner wears the warning style on purpose: a page showing a past minute as though it were the
597
+ fleet is the worst thing this dashboard could do, so it wears the loudest thing it has. */
598
+ /* Sticky, because the handle is at the bottom of a map that can be taller than the
599
+ viewport: a reader dragging with the "this is the past" banner scrolled off the top is
600
+ a reader the banner is not warning. */
601
+ .replaying-note:not([hidden]) { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap;
602
+ position:sticky; top:0; z-index:1; }
603
+ .replaying-note button { font:inherit; font-size:.75rem; font-weight:600; color:inherit;
604
+ background:transparent; border:1px solid currentColor; border-radius:99px;
605
+ padding:.05rem .7rem; cursor:pointer; }
606
+
408
607
  /* ── the map ─────────────────────────────────────────────────────────────────────────
409
608
  One node per session. The arc is the context, its weight is how much that reading may
410
609
  be believed, and the halo — the only thing on this page that moves — says a frame
@@ -413,6 +612,7 @@ export function renderPage(fleet, view = 'table') {
413
612
  .node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
414
613
  display:flex; flex-direction:column; align-items:center; text-align:center; }
415
614
  .node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
615
+ .node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
416
616
  /* An agent is a smaller body in the same system, next to the session it shares a directory
417
617
  with — never inside it. Tinted rather than outlined, and hooked, so it reads as the
418
618
  session's dependent without a line claiming a parentage the source never published. */
@@ -439,15 +639,28 @@ export function renderPage(fleet, view = 'table') {
439
639
  stroke-dasharray, which is what carries the percentage. */
440
640
  .node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
441
641
  stroke:var(--warn); stroke-width:2.5; opacity:.7; }
642
+ /* A replayed reading. Its EXTENT is what the record vouches for; its age is the one thing
643
+ the ring never kept, so it may not wear the solid arc that means "as current as a reading
644
+ gets" — nor the warning hue of a stale one, which would claim the opposite. Between the
645
+ two: full colour, a shade lighter, and no date underneath it. */
646
+ .node[data-reading="undatable"] .arc { stroke-width:4; opacity:.85; }
442
647
  /* Nothing was measured. Keyed on the measurement and never on the age of the file: a
443
648
  solid empty ring is what a session measured at 0% wears, and the two must not match. */
444
649
  .track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
445
650
  /* Once per arrival, not forever: the fragment is replaced on every poll, so a single run
446
651
  per swap is what makes the fleet breathe at the rate its frames actually land. A looping
447
652
  animation would say "a frame just arrived" for five seconds after it stopped being true. */
448
- .halo { fill:none; stroke:var(--busy); stroke-width:2; opacity:0; transform-origin:50% 50%;
653
+ /* What the halo says is that a frame landed, and it says it by being there at all. Its
654
+ COLOUR is free, and it was spending it on a claim: stroked with the busy hue under a lone
655
+ idle override, it pulsed green over an unrecognised status and — since the fourth state — over
656
+ a session halted on a human, in the hue of the one thing it is certainly not doing. Same
657
+ palette as the glyph under the name, off the same four states, so the two channels drawing
658
+ one node cannot end up disagreeing about it. */
659
+ .halo { fill:none; stroke:var(--dim); stroke-width:2; opacity:0; transform-origin:50% 50%;
449
660
  animation:halo 1.6s ease-out 1; }
450
- .node[data-state="idle"] .halo { stroke:var(--dim); }
661
+ .node[data-state="busy"] .halo { stroke:var(--busy); }
662
+ .node[data-state="waiting"] .halo { stroke:var(--wait); }
663
+ .node[data-state="unknown"] .halo { stroke:var(--warn); }
451
664
  @keyframes halo { from { opacity:.5; transform:scale(1); } to { opacity:0; transform:scale(1.22); } }
452
665
  /* Motion is the one thing here nobody can look away from, so it is the first thing a
453
666
  reader who asked for less of it stops getting. The reading is still readable without it. */
@@ -465,8 +678,12 @@ export function renderPage(fleet, view = 'table') {
465
678
  .node[data-state="busy"] .who .project { font-weight:700; }
466
679
  .shape { font-size:.7rem; color:var(--dim); }
467
680
  .node[data-state="busy"] .shape { color:var(--busy); }
681
+ .node[data-state="waiting"] .shape { color:var(--wait); }
468
682
  .node[data-state="unknown"] .shape { color:var(--warn); }
469
683
  .sub { color:var(--dim); font-size:.76rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
684
+ /* The one caption that is not a footnote: it is why this node is not working, and it sits
685
+ directly under the name in the state's own hue rather than in the grey of the rest. */
686
+ .sub.waiting-for { color:var(--wait); font-weight:600; }
470
687
  .asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
471
688
  .asof.stale { color:var(--warn); font-weight:600; }
472
689
  @media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
@@ -483,6 +700,7 @@ export function renderPage(fleet, view = 'table') {
483
700
  tr { border:1px solid var(--line); border-left-width:3px; border-radius:8px;
484
701
  padding:.35rem .7rem; margin-bottom:.6rem; }
485
702
  tr[data-state="busy"] { border-left-color:var(--busy); }
703
+ tr[data-state="waiting"] { border-left-color:var(--wait); }
486
704
  tr[data-state="unknown"] { border-left-color:var(--warn); }
487
705
  td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
488
706
  display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
@@ -503,6 +721,11 @@ export function renderPage(fleet, view = 'table') {
503
721
  <a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
504
722
  <a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
505
723
  </nav>
724
+ <!-- The account's two windows, page-level because that is what they are: a limit belongs to
725
+ the account every session below is spending from, not to any one of them. Their VALUES
726
+ come up from the fragment on every poll (the script's limits-src copy), so the header
727
+ structure can be the shell's without the numbers being as old as the tab. -->
728
+ <div class="limits" id="limits" role="group" aria-label="account rate limits">${gauges}</div>
506
729
  <!-- Not "updated just now". If the script never runs — a policy-injected CSP without
507
730
  'unsafe-inline', a script error — that text would stand as a permanent lie, and
508
731
  <noscript> would not fire to correct it because JavaScript is enabled. The page's one
@@ -510,12 +733,42 @@ export function renderPage(fleet, view = 'table') {
510
733
  <span class="freshness"><span class="pulse" aria-hidden="true"></span><span id="age">updated &mdash;</span></span>
511
734
  </header>
512
735
  <div class="warn offline" id="offline" hidden>
513
- <strong>&#9888; refresh failing</strong> — nothing below has moved since the time in the header.
736
+ <strong>&#9888; refresh failing</strong> — nothing on this page has moved since the time in the header.
514
737
  <span id="why"></span>
515
738
  </div>
739
+ <!-- The one claim on this page that could be a lie, so it is the loudest element on it and it
740
+ carries the minute it is showing. Hidden until a script raises it: with no script there
741
+ is no replay, and a banner about one would be a warning about nothing. -->
742
+ <!-- role="status" because it appears without a reload and without focus moving: drawn only,
743
+ it is the page's loudest claim and its most invisible one. -->
744
+ <div class="warn replaying-note" id="replaying" role="status" hidden>
745
+ <strong>&#9209; replaying <span id="replay-at"></span></strong>
746
+ <span>&mdash; a reading from the past, not the fleet now.</span>
747
+ <button type="button" id="to-live">Back to live</button>
748
+ </div>
516
749
  <noscript><div class="warn">JavaScript is off, so this page will not refresh itself. Reload it to see the fleet now.</div></noscript>
517
750
  <div id="live">${renderLive(fleet)}</div>
518
- <script>${SCRIPT}</script>
751
+ <!-- Where the past is drawn: the shell's own map, in the place the live one occupies, so
752
+ that swapping the fragment underneath cannot repaint what the reader is scrubbing. -->
753
+ <div id="replay-view" hidden>
754
+ <!-- The same pair, for the minute under the reader's hand — and here rather than in the
755
+ header, where the live pair sits. The banner that says "this is the past" is below the
756
+ header: an account drawn above it would be the one past number on the page with nothing
757
+ over it saying so, and the first thing a screen reader reaches, long before the warning.
758
+ Hidden until a script raises it: with no script there is no replay, and an empty gauge
759
+ would be a claim about nothing. -->
760
+ <div class="limits" id="replay-limits" role="group" aria-label="account rate limits, at the minute being replayed" hidden></div>
761
+ <div class="meta" id="replay-meta"></div>
762
+ <div class="map" id="replay-map"></div>
763
+ </div>
764
+ <!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
765
+ what it says it covers is whatever the record answered with. -->
766
+ <div class="replay" id="replay" hidden>
767
+ <button type="button" id="play">Play</button>
768
+ <input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
769
+ <div class="covers" id="covers"></div>
770
+ </div>
771
+ <script>${pageScript(view)}</script>
519
772
  </body></html>
520
773
  `;
521
774
  }
@@ -531,9 +784,15 @@ export function renderPage(fleet, view = 'table') {
531
784
  * polled for a reader who is not there. A poll is the only one of the three where the client
532
785
  * decides — so a hidden tab simply stops asking, and a waking one asks at once.
533
786
  *
534
- * The page therefore owns exactly two facts: when it last heard from the server, and whether
535
- * the last attempt failed. Everything a reader interprets is rendered by `renderLive` on the
536
- * server, where the suite can reach it.
787
+ * The page therefore owns exactly two facts about the present: when it last heard from the
788
+ * server, and whether the last attempt failed. Everything a reader interprets about NOW is
789
+ * rendered by `renderLive` on the server, where the suite can reach it.
790
+ *
791
+ * The replay below is the one exception, and it is one the issue asks for: scrubbing a day
792
+ * has to be a lookup in samples the page already holds, or every pixel of a drag would be a
793
+ * request and a `claude agents --json` behind it. So a second, smaller renderer lives in the
794
+ * browser — fed the same three words, the same three glyphs and the same dial geometry as the
795
+ * server's, by interpolation rather than by copy, and executed by `test/replay-script`.
537
796
  */
538
797
  export const REFRESH_MS = 5000;
539
798
  /**
@@ -542,10 +801,30 @@ export const REFRESH_MS = 5000;
542
801
  * on the server side first and arrives with a real reason instead of this generic one.
543
802
  */
544
803
  const STALL_MS = 20000;
545
- const SCRIPT = `
804
+ /**
805
+ * How fast play walks the record — one reading per step, so a serve that has seen ten minutes
806
+ * plays for a second and a full day for two and a half minutes. It is a step interval and not
807
+ * a total duration on purpose: the samples are not evenly spaced (a minute the collector
808
+ * missed is a minute nobody recorded), so a fixed run time would silently speed up over the
809
+ * gaps and make the day look busier than it was.
810
+ */
811
+ const PLAY_STEP_MS = 100;
812
+ /**
813
+ * The same walk for a reader who asked their system for less motion. Play is the one thing
814
+ * here that moves, and the honest answer to that preference is not to take the feature away —
815
+ * it is to stop flickering ten frames a second at someone who said that hurts.
816
+ */
817
+ const PLAY_STEP_CALM_MS = 1000;
818
+ /**
819
+ * A function, not a constant: it reads the vocabulary and the geometry declared below it, and
820
+ * it takes the view because only one of the two has a scrubber to feed.
821
+ */
822
+ function pageScript(view) {
823
+ return `
546
824
  (function () {
547
825
  var live = document.getElementById('live'), age = document.getElementById('age');
548
826
  var off = document.getElementById('offline'), why = document.getElementById('why');
827
+ var limits = document.getElementById('limits');
549
828
  var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
550
829
 
551
830
  function ago(ms) {
@@ -607,6 +886,15 @@ const SCRIPT = `
607
886
  if (body.trim() === '') throw new Error('The server answered with an empty page.');
608
887
  if (!mineStill()) return;
609
888
  live.innerHTML = body;
889
+ // The account's gauges, lifted out of the fragment and into the header where they
890
+ // belong. Here rather than in the fragment's own place on the page because a limit is
891
+ // the account's and not a session's; here rather than in the shell alone because the
892
+ // NUMBERS arrive with the fleet, and a five-hour window that stopped counting down
893
+ // would be the one thing on this page still claiming to be about now.
894
+ // Inside the accepted-answer branch on purpose: a body that was refused is a body
895
+ // nothing is read out of, the account's numbers included.
896
+ var src = document.getElementById('limits-src');
897
+ if (src) limits.innerHTML = src.innerHTML;
610
898
  last = Date.now();
611
899
  failing = false;
612
900
  });
@@ -625,15 +913,345 @@ const SCRIPT = `
625
913
  });
626
914
  }
627
915
 
916
+ // ── the day behind the present ──────────────────────────────────────────────────────
917
+ //
918
+ // The record is asked for once, and every drag after that is a lookup in it. The state of
919
+ // the replay lives here rather than in the fragment for the same reason the tabs do: /live
920
+ // is swapped wholesale every five seconds, and the reader's hand is not the server's to move.
921
+
922
+ var replay = document.getElementById('replay'), scrub = document.getElementById('scrub');
923
+ var playBtn = document.getElementById('play'), covers = document.getElementById('covers');
924
+ var rview = document.getElementById('replay-view'), rmap = document.getElementById('replay-map');
925
+ var rmeta = document.getElementById('replay-meta'), note = document.getElementById('replaying');
926
+ var rlimits = document.getElementById('replay-limits');
927
+ var atEl = document.getElementById('replay-at'), toLive = document.getElementById('to-live');
928
+ var record = null, recordAt = 0, at = -1, replaying = false, playing = null, hgen = 0;
929
+
930
+ // The vocabulary and the geometry, handed over rather than written twice: three words for
931
+ // the three kinds of missing, three glyphs for the three states, one dial radius.
932
+ var WHY = ${JSON.stringify(CTX_WHY)}, SHAPE = ${JSON.stringify(SHAPE)};
933
+ var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
934
+ var ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
935
+ var R = ${DIAL_R}, C = 2 * Math.PI * R;
936
+ var STEP = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
937
+ ? ${PLAY_STEP_CALM_MS} : ${PLAY_STEP_MS};
938
+
939
+ function esc(v) {
940
+ if (v === null || v === undefined || v === '') return '<span class="dim">—</span>';
941
+ return String(v).replace(/[&<>"']/g, function (c) { return ENT[c]; });
942
+ }
943
+
944
+ /** A lookup that cannot be answered by Object.prototype. */
945
+ function own(table, key) {
946
+ return Object.prototype.hasOwnProperty.call(table, key);
947
+ }
948
+
949
+ function two(n) { return (n < 10 ? '0' : '') + n; }
950
+ function hhmm(t) { var d = new Date(t); return two(d.getHours()) + ':' + two(d.getMinutes()); }
951
+
952
+ // A day-long ring can straddle one midnight, and "09:14 – 08:59" reads as a span running
953
+ // backwards until the older edge says which day it is.
954
+ function edge(t, ref) {
955
+ return hhmm(t) + (new Date(t).getDate() === new Date(ref).getDate() ? '' : ' yesterday');
956
+ }
957
+
958
+ // What the range covers, in the record's own terms. Never "a day": that is the size of the
959
+ // ring, and a serve ten minutes old has seen ten minutes.
960
+ function coversText() {
961
+ var n = record.samples.length;
962
+ if (n === 0) {
963
+ // A record empty because every reading FAILED is not a record that has just started, and
964
+ // this was the one branch that threw that away: ten hours of a collector that could not
965
+ // run read exactly like a serve thirty seconds old.
966
+ return record.missed
967
+ ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' and '
968
+ + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
969
+ : 'Nothing recorded yet — this serve started at ' + hhmm(record.since)
970
+ + ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
971
+ }
972
+ var last = record.samples[n - 1].t;
973
+ return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last)
974
+ // Not "when the page loaded": the record is asked for again when a tab that has been
975
+ // away comes back, so the sentence names the last time this page asked rather than a
976
+ // moment it may be hours past.
977
+ + ', as this page last had it — ' + n + ' reading' + (n === 1 ? '' : 's')
978
+ // A gap that says it is a gap is not a gap. The handle steps through readings, not
979
+ // through minutes, and a record with holes in it is not a smooth walk.
980
+ + (record.missed ? ', ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
981
+ + '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.';
982
+ }
983
+
984
+ function ready() {
985
+ var n = record.samples.length;
986
+ replay.hidden = false;
987
+ covers.textContent = coversText();
988
+ scrub.max = String(n === 0 ? 0 : n - 1);
989
+ scrub.disabled = n === 0;
990
+ playBtn.disabled = n === 0;
991
+ }
992
+
993
+ // Revealed, not hidden, when the record cannot be had: a scrubber that silently never
994
+ // appears is indistinguishable from one this build does not have.
995
+ function noRecord(said) {
996
+ replay.hidden = false;
997
+ scrub.disabled = true;
998
+ playBtn.disabled = true;
999
+ covers.textContent = said;
1000
+ }
1001
+
1002
+ function load() {
1003
+ // The same generation guard the fleet poll carries, for the same reason and one more.
1004
+ // The replaying flag is read when the tab regains focus; the answer lands later, and
1005
+ // a reader's hand can arrive in between — so the question is asked AGAIN at the moment of
1006
+ // the swap. Without it the record was replaced under a live scrub: the handle pointing at
1007
+ // one minute, the map drawing another, out of a record that no longer existed.
1008
+ var mine = ++hgen;
1009
+ return fetch('/api/history', { cache: 'no-store' }).then(function (res) {
1010
+ // The same refusal the fragment makes, for the same reason: what comes back is parsed
1011
+ // and drawn into this page, and loopback proves where bytes came from, not who wrote them.
1012
+ if (!res.headers.get('X-Tarmac')) throw new Error('The answer on this port did not come from tarmac.');
1013
+ return res.text().then(function (body) {
1014
+ if (!res.ok) throw new Error(body.split('\\n').filter(Boolean).join(' ').slice(0, 200));
1015
+ var got = JSON.parse(body);
1016
+ if (!got || !got.samples) throw new Error('the record came back in a shape this page does not know');
1017
+ if (mine !== hgen || replaying) return;
1018
+ record = got;
1019
+ recordAt = Date.now();
1020
+ ready();
1021
+ });
1022
+ }).catch(function (e) {
1023
+ if (mine !== hgen) return;
1024
+ // A refresh is not a first load. Failing one is no reason to take away a record the page
1025
+ // is already holding — and saying "the record could not be read" over one the reader is
1026
+ // scrubbing would be false. The fleet poll's own banner already says the server is quiet.
1027
+ if (record !== null) return;
1028
+ noRecord('The record could not be read — ' + String((e && e.message) || e).slice(0, 200));
1029
+ });
1030
+ }
1031
+
1032
+ // One node, out of what the ring holds and nothing more. No name, for any kind of session:
1033
+ // a background session is named after the prompt it was given, and the ring stores none.
1034
+ function nodeOf(x, anchored) {
1035
+ // The map's own rule, in the map's own words: an absent kind is not evidence of an agent,
1036
+ // and a fleet where nothing calls itself interactive is a fleet whose source moved.
1037
+ var role = !anchored || x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? 'session' : 'agent';
1038
+ // Own keys only. A bare read inherits from Object.prototype, so "constructor" and
1039
+ // "toString" passed this guard and reached the markup below — into an attribute
1040
+ // unescaped, and into the glyph slot as a function body.
1041
+ var state = own(SHAPE, x.state) ? x.state : 'unknown';
1042
+ var pct = typeof x.ctxPct === 'number' ? x.ctxPct : null;
1043
+ // The ring keeps each reading and never how old that reading was, so the arc weight that
1044
+ // says how much a reading may be believed cannot be earned here. It is not the live
1045
+ // default either: this third value is de-weighted in the stylesheet, and never the warning
1046
+ // hue, which would claim the opposite — that the reading is known to be old.
1047
+ return '<article class="node" data-role="' + role + '" data-state="' + state + '" data-reading="undatable">'
1048
+ // No halo, ever. It means a frame landed moments ago, which is never true of a sample.
1049
+ + '<div class="dial"><svg viewBox="0 0 80 80" aria-hidden="true">'
1050
+ + '<circle class="track' + (pct === null ? ' unmeasured' : '') + '" cx="40" cy="40" r="' + R + '"/>'
1051
+ + (pct === null ? '' : arcOf(pct))
1052
+ + '</svg><div class="val">'
1053
+ + (pct === null
1054
+ ? '<span class="why"><b>—</b>' + esc(own(WHY, x.ctxState) ? WHY[x.ctxState] : 'no reading') + '</span>'
1055
+ : '<span class="pct">' + pct + '<i>%</i></span>')
1056
+ + '</div></div>'
1057
+ + '<div class="who"><span class="shape" aria-hidden="true">' + SHAPE[state] + '</span>'
1058
+ + '<span class="sr">' + state + '</span>'
1059
+ + '<span class="project">' + esc(x.project) + '</span></div>'
1060
+ // The one caption the ring can fill. Guarded on the state as well as on the field: a
1061
+ // reason left over beside another state is not a session waiting for anything, and esc()
1062
+ // answers an absent field with a dash, which would caption a node "waiting for —".
1063
+ + (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
1064
+ + (x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? '' : '<div class="sub">' + esc(x.kind) + '</div>')
1065
+ + (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
1066
+ + '</article>';
1067
+ }
1068
+
1069
+ // The server's own arithmetic, off the server's own radius: a fraction of the real
1070
+ // circumference, never pathLength, so a browser that ignores it cannot close every ring
1071
+ // into a full context window.
1072
+ function arcOf(pct) {
1073
+ var filled = (Math.min(100, Math.max(0, pct)) / 100) * C;
1074
+ var r2 = function (n) { return Math.round(n * 100) / 100; };
1075
+ return '<circle class="arc" cx="40" cy="40" r="' + R + '" transform="rotate(-90 40 40)"'
1076
+ + ' stroke-dasharray="' + r2(filled) + ' ' + r2(C - filled) + '"/>';
1077
+ }
1078
+
1079
+ // ── the account, as it stood that minute ────────────────────────────────────────────
1080
+ //
1081
+ // The second thing this page interprets twice, and for the same reason as the dials: a
1082
+ // replay is a lookup in samples the page already holds, and the ring holds the payload's own
1083
+ // rate_limits rather than anything rendered. The vocabulary, the dash and the two windows are
1084
+ // handed over below rather than written again; what is mirrored is the arithmetic, and a
1085
+ // test compares this output with the server's character for character.
1086
+ //
1087
+ // What it counts the reset against is the SAMPLE's own clock, never Date.now(). A reset is a
1088
+ // moment, and "how long is left" is a question about the minute being replayed: at 09:14 the
1089
+ // five-hour window had two hours to run, and it had two hours to run whatever time it is now.
1090
+ // Counted against the present, every reset in the record would read as long overdue the
1091
+ // moment it aged past — a page announcing an account over its limit for a day that ended.
1092
+ var LIMITS = ${JSON.stringify(LIMIT_WINDOWS)}, LIMIT_WHY = ${JSON.stringify(LIMIT_WHY)};
1093
+ var DASH = ${JSON.stringify(dash())};
1094
+
1095
+ function left(ms) {
1096
+ var m = Math.floor(ms / 60000);
1097
+ if (m < 1) return '<1m';
1098
+ if (m < 60) return m + 'm';
1099
+ var h = Math.floor(m / 60);
1100
+ if (h < 24) return m % 60 === 0 ? h + 'h' : h + 'h ' + (m % 60) + 'm';
1101
+ var d = Math.floor(h / 24);
1102
+ return h % 24 === 0 ? d + 'd' : d + 'd ' + (h % 24) + 'h';
1103
+ }
1104
+
1105
+ function gaugesOf(rl, now) {
1106
+ // Anything can be in a sample: rate_limits is a shape someone else versions, and the ring
1107
+ // stored whatever the payload had. None of it may throw in the header of a dashboard.
1108
+ var ok = rl !== null && rl !== undefined && typeof rl === 'object' && !Array.isArray(rl);
1109
+ var html = '';
1110
+ for (var i = 0; i < LIMITS.length; i++) {
1111
+ var w = ok ? rl[LIMITS[i].key] : undefined;
1112
+ var has = w !== null && w !== undefined && typeof w === 'object' && !Array.isArray(w) && 'used_percentage' in w;
1113
+ var v = has ? w.used_percentage : undefined;
1114
+ var pct = has && typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? Math.floor(v) : null;
1115
+ var at = has && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;
1116
+ var ms = at === null ? null : at * 1000 - now;
1117
+ // The server's horizon, off the server's own number: a reset further from the reading than
1118
+ // the longest window can be is not this account's reset, whatever it says.
1119
+ if (ms !== null && Math.abs(ms) > ${RESET_HORIZON_MS}) ms = null;
1120
+ // Presence, never value: a window that is there and null is a number not taken yet, and
1121
+ // one that is gone is a schema that moved. Same discriminant as everywhere else here.
1122
+ // Read off rl and NOT off ok: rate_limits carrying something that is not a pair of
1123
+ // windows — an array, which the snapshot reader lets through — is a schema that moved,
1124
+ // not an account nobody measured. Written as !ok, this said the opposite of the server
1125
+ // about the very same minute.
1126
+ var why = pct !== null ? null : (rl === null || rl === undefined || (has && v === null)) ? 'absent' : 'drift';
1127
+ html += '<div class="gauge"><span class="lbl" aria-hidden="true">' + LIMITS[i].label + '</span>'
1128
+ + '<span class="sr">' + LIMITS[i].said + '</span>'
1129
+ + (pct === null
1130
+ ? '<span class="rail unmeasured" aria-hidden="true"></span>'
1131
+ : '<span class="rail" aria-hidden="true"><i style="width:' + pct + '%"></i></span>')
1132
+ + '<span class="num">' + (pct === null ? DASH : pct + '%') + '</span>'
1133
+ + '<span class="reset">'
1134
+ + (pct === null
1135
+ ? LIMIT_WHY[why]
1136
+ : ms === null
1137
+ ? 'reset ' + DASH
1138
+ : ms > 0 ? 'resets in ' + left(ms) : 'reset was due ' + left(-ms) + ' ago')
1139
+ + '</span></div>';
1140
+ }
1141
+ return html;
1142
+ }
1143
+
1144
+ function nodesOf(s) {
1145
+ var anchored = false, html = '', i;
1146
+ for (i = 0; i < s.sessions.length; i++) if (s.sessions[i].kind === INTERACTIVE) anchored = true;
1147
+ // In the order the sample carries. The live map places an agent beside the session it
1148
+ // shares a directory with; the ring holds no directory, so the past is drawn in the order
1149
+ // the fleet was sorted in rather than in a grouping this page would have to invent.
1150
+ for (i = 0; i < s.sessions.length; i++) html += nodeOf(s.sessions[i], anchored);
1151
+ return html;
1152
+ }
1153
+
1154
+ // The fleet of that minute, counted from that minute. A partial sum is never presented as
1155
+ // the total, the same rule the live header follows.
1156
+ function metaOf(s) {
1157
+ var n = s.sessions.length, busy = 0, cost = 0, reporting = 0;
1158
+ for (var i = 0; i < n; i++) {
1159
+ if (s.sessions[i].state === 'busy') busy++;
1160
+ if (typeof s.sessions[i].costUsd === 'number') { cost += s.sessions[i].costUsd; reporting++; }
1161
+ }
1162
+ return n + ' session' + (n === 1 ? '' : 's') + ' · ' + busy + ' busy · '
1163
+ + (reporting === 0 ? 'cost —'
1164
+ : '$' + cost.toFixed(2) + (reporting < n ? ' (' + reporting + '/' + n + ' reporting cost)' : ''));
1165
+ }
1166
+
1167
+ function draw(i) {
1168
+ var s = record && record.samples[i];
1169
+ if (!s) return;
1170
+ at = i;
1171
+ replaying = true;
1172
+ scrub.value = String(i);
1173
+ // The handle's own value is an index, so a reader who cannot see the banner would be read
1174
+ // "3" while the fleet on screen is three hours old. The minute travels with the handle.
1175
+ scrub.setAttribute('aria-valuetext', hhmm(s.t));
1176
+ atEl.textContent = hhmm(s.t);
1177
+ rmeta.textContent = metaOf(s);
1178
+ rmap.innerHTML = nodesOf(s);
1179
+ // The account of that minute, in the place the live pair occupies — which the body class
1180
+ // has just taken down. One allowance on screen at a time, and it is the one belonging to
1181
+ // the fleet being shown.
1182
+ rlimits.innerHTML = gaugesOf(s.rateLimits, s.t);
1183
+ rlimits.hidden = false;
1184
+ note.hidden = false;
1185
+ rview.hidden = false;
1186
+ document.body.classList.toggle('replaying', true);
1187
+ }
1188
+
1189
+ function stopPlay() {
1190
+ if (playing) { clearInterval(playing); playing = null; }
1191
+ playBtn.textContent = 'Play';
1192
+ }
1193
+
1194
+ // Back to now, in one gesture, with nothing of the past left behind a hidden attribute.
1195
+ // The position is NOT reset: the handle stays where the reader let go of it, so what it
1196
+ // shows and where play would pick up are the same place.
1197
+ function present() {
1198
+ stopPlay();
1199
+ replaying = false;
1200
+ note.hidden = true;
1201
+ rview.hidden = true;
1202
+ rlimits.hidden = true;
1203
+ rlimits.innerHTML = '';
1204
+ rmap.innerHTML = '';
1205
+ scrub.removeAttribute('aria-valuetext');
1206
+ document.body.classList.toggle('replaying', false);
1207
+ }
1208
+
1209
+ function play() {
1210
+ if (playing) { stopPlay(); return; }
1211
+ if (!record || record.samples.length === 0) return;
1212
+ // From the top when there is nothing to resume: a play button that ends where it started
1213
+ // has played nothing.
1214
+ draw(at < 0 || at >= record.samples.length - 1 ? 0 : at);
1215
+ playBtn.textContent = 'Pause';
1216
+ playing = setInterval(function () {
1217
+ // It stops at the end rather than looping back: a day that restarts on its own is a
1218
+ // day whose beginning and end are impossible to tell apart.
1219
+ if (at >= record.samples.length - 1) { stopPlay(); return; }
1220
+ draw(at + 1);
1221
+ }, STEP);
1222
+ }
1223
+
1224
+ scrub.addEventListener('input', function () { stopPlay(); draw(Number(scrub.value)); });
1225
+ playBtn.addEventListener('click', play);
1226
+ toLive.addEventListener('click', present);
1227
+
628
1228
  setInterval(tick, 1000);
629
1229
  setInterval(function () { if (!document.hidden) poll(); }, ${REFRESH_MS});
630
- document.addEventListener('visibilitychange', function () { if (!document.hidden) poll(); });
1230
+ document.addEventListener('visibilitychange', function () {
1231
+ if (document.hidden) return;
1232
+ poll();
1233
+ // The record was answered once, at load. A tab left alone all afternoon holds a record
1234
+ // that stops where the reader's attention did — so it is asked again on the way back in.
1235
+ // Never while a reader is scrubbing (the record under their hand is not ours to swap, and
1236
+ // the load asks that question again when the answer lands), and never for one younger than
1237
+ // a single slot. A record that could never be read at all is not retried here: it stays
1238
+ // null, the guard holds, and the reader has a sentence saying so rather than a page
1239
+ // quietly trying again forever.
1240
+ if (!replaying && record !== null && Date.now() - recordAt >= record.cadence) load();
1241
+ });
1242
+ // Only where there is a scrubber to feed. The table view hides these controls in CSS, and a
1243
+ // full ring is megabytes of session ids, projects and costs: fetching and parsing it to
1244
+ // write a sentence into an element with display:none is a cost paid on every load of the
1245
+ // page most people open first, for a control they cannot see.
1246
+ if (${view === 'map'}) load();
631
1247
  })();
632
1248
  `;
1249
+ }
633
1250
  /**
634
- * The sort puts busy first, unknown next, idle last. This is where that order is given its
635
- * weight an accent down the row and a bold name for the ones that are working, a quiet row
636
- * for the ones that are not.
1251
+ * The sort puts waiting first the one row that is work for the reader then busy, then
1252
+ * unknown, idle last. This is where that order is given its weight an accent down the row
1253
+ * in the state's own hue, a bold name for the ones that are working, a quiet row for the
1254
+ * ones that are not.
637
1255
  *
638
1256
  * The state travels three ways at once: a shape, a word, and an attribute. Colour alone is
639
1257
  * no signal to a reader who cannot separate two of ours, and `data-state` is what the narrow
@@ -641,7 +1259,7 @@ const SCRIPT = `
641
1259
  */
642
1260
  function renderRow(r) {
643
1261
  const state = stateOf(r);
644
- const word = stateWord(state, r);
1262
+ const word = stateLabel(state, r);
645
1263
  // `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
646
1264
  // and a value whose column has no name is a bare "—" that could mean anything.
647
1265
  // Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
@@ -658,7 +1276,7 @@ function renderRow(r) {
658
1276
  <td data-label="Uptime" class="num dim"><span class="v">${r.uptimeMs === null ? dash() : esc(duration(r.uptimeMs))}</span></td>
659
1277
  </tr>`;
660
1278
  }
661
- const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
1279
+ const SHAPE = { busy: '●', waiting: '◐', unknown: '▲', idle: '○' };
662
1280
  /**
663
1281
  * The state in words, for both surfaces — derived from the state the MODEL decided, never
664
1282
  * from the row a second time. Two expressions for one fact on one element is how a node ends
@@ -668,6 +1286,13 @@ const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
668
1286
  * of keeping it is that someone reading the page can go and find out what `compacting` means.
669
1287
  */
670
1288
  const stateWord = (state, r) => state === 'unknown' ? (r.status ?? 'unknown') : state;
1289
+ /**
1290
+ * The same word with the reason attached, for the table — which has one cell per session and
1291
+ * no room for a caption of its own. The map keeps them apart instead: the word is what a
1292
+ * screen reader is handed in place of the glyph, and repeating the reason there would read it
1293
+ * twice, once hidden and once out of the caption below it.
1294
+ */
1295
+ const stateLabel = (state, r) => state === 'waiting' && r.waitingFor ? `${stateWord(state, r)} · ${r.waitingFor}` : stateWord(state, r);
671
1296
  /**
672
1297
  * Which kind of missing a missing percentage is. One lookup for both surfaces: the table
673
1298
  * says it beside a dash, the map says it inside an empty dial, and a second copy of these
@@ -697,6 +1322,10 @@ export function renderMap(fleet) {
697
1322
  * dial is no reading at all, the shape beside the name is the session's state, and the halo
698
1323
  * says one landed moments ago. The words under them are the same ones the table uses for the
699
1324
  * same conditions — including the halo's, which would otherwise live only in a drawing.
1325
+ *
1326
+ * One state brings a caption with it. A waiting session is the only one where the shape
1327
+ * leaves a question the source can answer — which human answer it is halted on — and it is
1328
+ * printed directly under the name, not hidden in a title attribute nobody hovers on a phone.
700
1329
  */
701
1330
  function renderNode({ row: r, role, state, reading, measured, pulse }) {
702
1331
  // The model owns "is there a number"; this reads its verdict rather than asking the row a
@@ -722,6 +1351,7 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
722
1351
  ${pulse ? `<span class="sr">a reading just landed</span>` : ''}
723
1352
  </div>
724
1353
  <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>
1354
+ ${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
725
1355
  <div class="sub">${esc(r.name)}</div>
726
1356
  ${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
727
1357
  <div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>