@adrrr/tarmac 0.1.2 → 0.3.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,6 +7,7 @@
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';
11
12
  /**
12
13
  * The other thing this module renders: the plan a user consents to before install or
@@ -27,9 +28,41 @@ export function renderPlan(plan) {
27
28
  rows.push(['↳ which calls', `${plan.chained} (your display is unchanged)`]);
28
29
  if (plan.alreadyInstalled)
29
30
  rows.push(['note', 'already installed — the wrapper is regenerated, settings.json is left alone']);
31
+ // The snapshots directory is no longer under `.claude`, so it is no longer guessable
32
+ // from the path above it: naming it here is how a reader of `list`, `serve` or any other
33
+ // tool finds out where the payloads land.
34
+ rows.push(['snapshots', plan.snapshots]);
35
+ // A relocation is a change to where the telemetry lands, so it is never implied.
36
+ if (plan.movingFrom !== null)
37
+ rows.push(['↳ moving from', `${plan.movingFrom} (its payloads are left there, and nothing collects them)`]);
38
+ // This operation now DELETES files, inside a directory people commit. A plan that can
39
+ // disagree with what runs is worse than no plan — so it says how many, and where.
40
+ // A directory that is THERE but holds none of our payloads is the state the previous
41
+ // install left behind: nothing to announce, and nothing to ask anyone to commit.
42
+ const clearing = plan.legacy !== null && plan.legacy.payloads > 0;
43
+ if (clearing) {
44
+ rows.push([
45
+ '↳ clearing',
46
+ `${plan.legacy.payloads} runtime payload(s) under ${plan.legacy.dir} — each one is written again on the next frame`,
47
+ ]);
48
+ if (plan.legacy.kept > 0)
49
+ rows.push(['↳ keeping', `${plan.legacy.kept} file(s) nothing here wrote, so that directory stays`]);
50
+ }
51
+ if (plan.gitRepo !== null)
52
+ rows.push(['git', gitHint(plan.gitRepo, clearing)]);
30
53
  }
31
54
  else {
32
55
  rows.push(['restore', `${plan.mode} — ${restoreMeaning(plan.mode)}`]);
56
+ // Nothing here says where the payloads are — no wrapper, or one that no longer carries
57
+ // the path, or one we cannot read. There is no directory to name, and `uninstall` opens
58
+ // none and removes nothing in one. Printing the default we would have computed, beside a
59
+ // promise to clear a marker out of it, is a plan disagreeing with what runs.
60
+ if (plan.snapshots === null) {
61
+ rows.push(['snapshots', 'unknown — nothing here says where; nothing there is opened or removed']);
62
+ }
63
+ else {
64
+ rows.push(['snapshots', `${plan.snapshots} (snapshot files stay; ${markerFate(plan.mode, plan.marker)})`]);
65
+ }
33
66
  }
34
67
  rows.push(['undo', plan.undo]);
35
68
  const w = Math.max(...rows.map(([label]) => label.length));
@@ -37,6 +70,39 @@ export function renderPlan(plan) {
37
70
  rows.map(([label, value]) => ` ${label.padEnd(w)} ${value}\n`).join('') +
38
71
  '\n');
39
72
  }
73
+ /**
74
+ * The line #20 asked for, said once, to the only people it concerns: those whose `.claude`
75
+ * is a git repository.
76
+ *
77
+ * It has two jobs, and which one is live depends on whether the payloads are still there:
78
+ * an install that clears them produces a DELETION the user has to commit, and a `.gitignore`
79
+ * line keeps them from coming back if that directory is ever pointed at again. With nothing
80
+ * to clear, the only thing left to say is that this install adds nothing that churns.
81
+ */
82
+ const gitHint = (repo, hasLegacy) => `${repo.dir} is a git repository — ` +
83
+ (hasLegacy
84
+ ? `commit the removal above, and add \`${repo.ignore}\` to its .gitignore`
85
+ : 'nothing tarmac writes there changes at runtime; the snapshots live outside it');
86
+ /**
87
+ * What becomes of the prune marker, said only after looking at it.
88
+ *
89
+ * Three of these four answers are "it stays", each for its own reason: a foreign statusLine
90
+ * keeps the wrapper, so the marker keeps its owner; nothing is there to take; or what is there
91
+ * is not a plain file, which `removePruneMarker` refuses by design because `unlink` would take
92
+ * a link and not its target.
93
+ *
94
+ * Written so that ONLY `'file'` can reach the removal sentence, rather than letting it be the
95
+ * fallthrough: `marker` is nullable by type, and a null landing on "is removed" would be the
96
+ * exact promise this whole change exists to stop making. The safe answer is the default; the
97
+ * dangerous one is the special case.
98
+ */
99
+ const markerFate = (mode, marker) => marker !== 'file'
100
+ ? marker === 'not-a-file'
101
+ ? "the prune marker's name is worn by something that is not a regular file, so it stays"
102
+ : 'no prune marker to remove'
103
+ : mode === 'foreign'
104
+ ? "tarmac's prune marker stays"
105
+ : "tarmac's prune marker is removed";
40
106
  /** What each restore mode means, in the words the plan and the report both use. */
41
107
  export const restoreMeaning = (mode) => RESTORE_MEANING[mode];
42
108
  const RESTORE_MEANING = {
@@ -110,7 +176,13 @@ export function renderTable({ rows, health }) {
110
176
  else if (health.schemaBroken)
111
177
  warns.push('! every snapshot drifted — the statusline payload schema moved');
112
178
  else if (health.covered < health.sessions)
113
- warns.push(`! statusline chained on ${health.covered}/${health.sessions} sessions`);
179
+ warns.push(
180
+ // The count travels, for the same reason `unreadable` does one line up: without it
181
+ // this line reads as "run install", and for a session id the wrapper declines to file
182
+ // that is advice already taken which can never work.
183
+ health.unfilable > 0
184
+ ? `! statusline chained on ${health.covered}/${health.sessions} sessions — ${health.unfilable} session(s) with an id tarmac never files`
185
+ : `! statusline chained on ${health.covered}/${health.sessions} sessions`);
114
186
  if (health.stale > 0)
115
187
  warns.push(`! ${health.stale} reading(s) marked "!" are older than ${formatDuration(health.staleAfterMs)} (--stale-after)`);
116
188
  const skewed = rows.filter(ahead).length;
@@ -156,7 +228,8 @@ function age(ms) {
156
228
  * could not see) are hard-won and tested; re-deriving them in browser JavaScript to redraw a
157
229
  * polled row would put the second copy somewhere this suite cannot reach.
158
230
  */
159
- export function renderLive({ rows, health }) {
231
+ export function renderLive(fleet) {
232
+ const { rows, health } = fleet;
160
233
  const warnings = [];
161
234
  if (health.noSessionId > 0) {
162
235
  // Never "no sessions found" when discovery DID find some it could not identify.
@@ -178,7 +251,12 @@ export function renderLive({ rows, health }) {
178
251
  warnings.push(`Every snapshot drifted — Claude Code's statusline schema has probably moved. Context readings are dead until the payload shape is re-checked.`);
179
252
  }
180
253
  else if (health.covered < health.sessions) {
181
- warnings.push(`Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`);
254
+ const blind = health.sessions - health.covered;
255
+ warnings.push(health.unfilable === 0
256
+ ? `Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`
257
+ : health.unfilable >= blind
258
+ ? `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.`
259
+ : `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.`);
182
260
  }
183
261
  if (health.unknownStatus > 0) {
184
262
  warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
@@ -193,22 +271,34 @@ export function renderLive({ rows, health }) {
193
271
  const schema = schemaNotice(health.schemaGuard);
194
272
  if (schema)
195
273
  warnings.push(schema);
274
+ // Both views, every time, out of the one reading the page just asked for. The tabs are
275
+ // links and the shell decides which of the two is visible, so a fleet cannot be drawn as a
276
+ // table of one age beside a map of another.
277
+ //
278
+ // A fleet with nothing in it has no two ways to be laid out, so it gets one sentence above
279
+ // both of them rather than a copy inside each — the copy behind `display:none` was invisible
280
+ // on screen and read out all the same by anything going through the markup.
196
281
  const body = rows.length === 0
197
- ? health.noSessionId > 0
198
- ? // Discovery DID return entries — we just could not identify them. Saying "none
199
- // found" here would hide a schema change behind a calm, wrong answer.
200
- `<p class="empty">No session could be identified, though ${health.noSessionId} were discovered.</p>`
201
- : `<p class="empty">No Claude Code sessions found. Is a session running?</p>`
202
- : `<table>
282
+ ? empty(health)
283
+ : `<div class="view view-table"><div class="wrap"><table>
203
284
  <thead><tr>
204
285
  <th>Project</th><th>Session</th><th>State</th><th>Context</th><th>Model</th><th>Effort</th><th>Cost</th><th>Uptime</th>
205
286
  </tr></thead>
206
287
  <tbody>${rows.map(renderRow).join('')}</tbody>
207
- </table>`;
288
+ </table></div></div>
289
+ <div class="view view-map">${renderMap(fleet)}</div>`;
208
290
  return `<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
209
291
  ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
210
- <div class="wrap">${body}</div>`;
292
+ ${body}`;
211
293
  }
294
+ /**
295
+ * What to say instead of a fleet. Discovery returning entries we could not identify is not
296
+ * an empty fleet, and saying "none found" would hide a schema change behind a calm, wrong
297
+ * answer — so the two surfaces below share one wording rather than each keeping its own.
298
+ */
299
+ const empty = (health) => health.noSessionId > 0
300
+ ? `<p class="empty">No session could be identified, though ${health.noSessionId} were discovered.</p>`
301
+ : `<p class="empty">No Claude Code sessions found. Is a session running?</p>`;
212
302
  /**
213
303
  * One frame of `tarmac list --watch`. It owes the reader exactly what the page owes: the
214
304
  * table, when the reading in it arrived, and whether the last attempt to refresh it failed.
@@ -255,7 +345,7 @@ function ago(ms) {
255
345
  const m = Math.round(s / 60);
256
346
  return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
257
347
  }
258
- export function renderPage(fleet) {
348
+ export function renderPage(fleet, view = 'table') {
259
349
  return `<!doctype html>
260
350
  <html lang="en"><head>
261
351
  <meta charset="utf-8">
@@ -306,6 +396,81 @@ export function renderPage(fleet) {
306
396
  body.failing .pulse { background:var(--warn); }
307
397
  body.failing #live { border:1px dashed var(--warn); border-radius:8px; padding:.5rem; }
308
398
  .offline strong { white-space:nowrap; }
399
+ /* The tabs, and what they hide. The shell owns the choice — not the fragment — so a poll
400
+ that swaps the fleet underneath cannot put the reader back on a view they left. */
401
+ nav { display:flex; gap:.15rem; }
402
+ nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
403
+ letter-spacing:.06em; padding:.15rem .55rem; border-radius:99px; border:1px solid transparent; }
404
+ nav a[aria-current="page"] { color:var(--fg); border-color:var(--line); }
405
+ body[data-view="table"] .view-map { display:none; }
406
+ body[data-view="map"] .view-table { display:none; }
407
+
408
+ /* ── the map ─────────────────────────────────────────────────────────────────────────
409
+ One node per session. The arc is the context, its weight is how much that reading may
410
+ be believed, and the halo — the only thing on this page that moves — says a frame
411
+ landed moments ago. */
412
+ .map { display:grid; gap:.9rem; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr)); }
413
+ .node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
414
+ display:flex; flex-direction:column; align-items:center; text-align:center; }
415
+ .node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
416
+ /* An agent is a smaller body in the same system, next to the session it shares a directory
417
+ with — never inside it. Tinted rather than outlined, and hooked, so it reads as the
418
+ session's dependent without a line claiming a parentage the source never published. */
419
+ .node[data-role="agent"] { padding-top:.55rem; border-color:transparent;
420
+ background:color-mix(in srgb, var(--line) 22%, transparent); }
421
+ .node[data-role="agent"] .dial { width:3.6rem; height:3.6rem; }
422
+ .node[data-role="agent"] .pct { font-size:.95rem; }
423
+ .node[data-role="agent"] .why { font-size:.6rem; max-width:3.4rem; }
424
+ .node[data-role="agent"] .why b { font-size:1rem; }
425
+ .node[data-role="agent"] .project { font-weight:400; }
426
+ /* Said, not shown: the three glyphs differ in silhouette, so a reader who cannot separate
427
+ two hues still has the state — but a screen reader is handed a bullet and nothing else. */
428
+ .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
429
+ .dial { position:relative; width:5.5rem; height:5.5rem; }
430
+ .dial svg { width:100%; height:100%; display:block; overflow:visible; }
431
+ .track { fill:none; stroke:var(--line); stroke-width:5; }
432
+ /* Butt caps, not round: a rounded cap adds half a stroke width at each end, which draws a
433
+ 1% reading at three times its extent. The prettier cap overstates every small number. */
434
+ .arc { fill:none; stroke:var(--dim); stroke-width:5; }
435
+ .node[data-state="busy"] .arc { stroke:var(--busy); }
436
+ /* A reading past the freshness threshold is drawn as what it is: thin, faded, and in the
437
+ warning hue — never the solid arc of a live one. Its EXTENT stays true, because the
438
+ number is still the truth of an earlier moment; and a dash pattern here would overwrite
439
+ stroke-dasharray, which is what carries the percentage. */
440
+ .node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
441
+ stroke:var(--warn); stroke-width:2.5; opacity:.7; }
442
+ /* Nothing was measured. Keyed on the measurement and never on the age of the file: a
443
+ solid empty ring is what a session measured at 0% wears, and the two must not match. */
444
+ .track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
445
+ /* Once per arrival, not forever: the fragment is replaced on every poll, so a single run
446
+ per swap is what makes the fleet breathe at the rate its frames actually land. A looping
447
+ 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%;
449
+ animation:halo 1.6s ease-out 1; }
450
+ .node[data-state="idle"] .halo { stroke:var(--dim); }
451
+ @keyframes halo { from { opacity:.5; transform:scale(1); } to { opacity:0; transform:scale(1.22); } }
452
+ /* Motion is the one thing here nobody can look away from, so it is the first thing a
453
+ reader who asked for less of it stops getting. The reading is still readable without it. */
454
+ /* Scaled, not merely stopped: at rest the halo sits inside the track's own stroke, so
455
+ "animation:none" alone left the one fact it carries invisible. */
456
+ @media (prefers-reduced-motion: reduce) { .halo { animation:none; opacity:.35; transform:scale(1.18); } }
457
+ .val { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
458
+ font-variant-numeric:tabular-nums; }
459
+ .pct { font-size:1.25rem; font-weight:650; letter-spacing:-.01em; }
460
+ .pct i { font-style:normal; font-size:.7em; font-weight:500; color:var(--dim); }
461
+ .why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
462
+ .why b { display:block; font-size:1.25rem; font-weight:400; }
463
+ .who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
464
+ .who .project { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
465
+ .node[data-state="busy"] .who .project { font-weight:700; }
466
+ .shape { font-size:.7rem; color:var(--dim); }
467
+ .node[data-state="busy"] .shape { color:var(--busy); }
468
+ .node[data-state="unknown"] .shape { color:var(--warn); }
469
+ .sub { color:var(--dim); font-size:.76rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
470
+ .asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
471
+ .asof.stale { color:var(--warn); font-weight:600; }
472
+ @media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
473
+
309
474
  /* Below this the table stops being a table: one card per session, every value keeping the
310
475
  name of the column it came from. Nothing is dropped — a phone that hides the context
311
476
  column would be a phone that renders "not measured" as nothing at all. */
@@ -327,9 +492,17 @@ export function renderPage(fleet) {
327
492
  .bar { display:none; }
328
493
  }
329
494
  </style>
330
- </head><body>
495
+ </head><body data-view="${view}">
331
496
  <header>
332
497
  <h1>tarmac</h1>
498
+ <!-- Links, not buttons: the view survives a reload, a bookmark and a browser with
499
+ JavaScript off — the state of a page whose own noscript banner promises it is still
500
+ readable. Both views are in the fragment below either way, so switching costs the
501
+ server nothing and the two can never show readings of different ages. -->
502
+ <nav>
503
+ <a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
504
+ <a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
505
+ </nav>
333
506
  <!-- Not "updated just now". If the script never runs — a policy-injected CSP without
334
507
  'unsafe-inline', a script error — that text would stand as a permanent lie, and
335
508
  <noscript> would not fire to correct it because JavaScript is enabled. The page's one
@@ -468,7 +641,7 @@ const SCRIPT = `
468
641
  */
469
642
  function renderRow(r) {
470
643
  const state = stateOf(r);
471
- const word = r.busy === true ? 'busy' : r.busy === false ? 'idle' : (r.status ?? 'unknown');
644
+ const word = stateWord(state, r);
472
645
  // `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
473
646
  // and a value whose column has no name is a bare "—" that could mean anything.
474
647
  // Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
@@ -485,12 +658,106 @@ function renderRow(r) {
485
658
  <td data-label="Uptime" class="num dim"><span class="v">${r.uptimeMs === null ? dash() : esc(duration(r.uptimeMs))}</span></td>
486
659
  </tr>`;
487
660
  }
488
- const stateOf = (r) => (r.busy === true ? 'busy' : r.busy === false ? 'idle' : 'unknown');
489
661
  const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
662
+ /**
663
+ * The state in words, for both surfaces — derived from the state the MODEL decided, never
664
+ * from the row a second time. Two expressions for one fact on one element is how a node ends
665
+ * up shaped `unknown` and captioned `idle`.
666
+ *
667
+ * An unrecognised status is quoted as it came rather than flattened to "unknown": the point
668
+ * of keeping it is that someone reading the page can go and find out what `compacting` means.
669
+ */
670
+ const stateWord = (state, r) => state === 'unknown' ? (r.status ?? 'unknown') : state;
671
+ /**
672
+ * Which kind of missing a missing percentage is. One lookup for both surfaces: the table
673
+ * says it beside a dash, the map says it inside an empty dial, and a second copy of these
674
+ * three words is a second chance to describe the same state differently.
675
+ */
676
+ const CTX_WHY = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' };
677
+ /**
678
+ * The map: one node per session, laid out as a grid rather than a graph. An empty fleet is not
679
+ * its business — `renderLive` says that once, above both views, rather than letting each of
680
+ * them render the same sentence and hide one of the two.
681
+ *
682
+ * There are no edges because the sources publish no relationship between two sessions — the
683
+ * one thing they do carry is the working directory, and that is expressed by putting an
684
+ * agent NEXT to the session it shares a directory with, never by drawing a line that would
685
+ * claim more than the data says.
686
+ *
687
+ * Everything a reader interprets is decided in `map.ts` and rendered here, on the server,
688
+ * for the same reason the table is: the rules that keep a reading honest are tested, and a
689
+ * copy of them re-derived in browser JavaScript would sit where this suite cannot reach.
690
+ */
691
+ export function renderMap(fleet) {
692
+ return `<div class="map">${buildMap(fleet).nodes.map(renderNode).join('')}</div>`;
693
+ }
694
+ /**
695
+ * One node. Five facts, in five channels that do not depend on colour alone: the arc is how
696
+ * full the context is, the dial's weight is how much that reading may be believed, a dotted
697
+ * dial is no reading at all, the shape beside the name is the session's state, and the halo
698
+ * says one landed moments ago. The words under them are the same ones the table uses for the
699
+ * same conditions — including the halo's, which would otherwise live only in a drawing.
700
+ */
701
+ function renderNode({ row: r, role, state, reading, measured, pulse }) {
702
+ // The model owns "is there a number"; this reads its verdict rather than asking the row a
703
+ // second question. `fresh` and `drift` are the two states where the age of the file and the
704
+ // presence of a reading disagree, and they are the two that matter most.
705
+ const pct = measured ? r.ctxPct : null;
706
+ const value = pct === null
707
+ ? `<span class="why"><b>&mdash;</b>${esc(CTX_WHY[r.ctxState] ?? 'no reading')}</span>`
708
+ : `<span class="pct">${pct}<i>%</i></span>`;
709
+ // The reading's own age, and only when it is one the reader must not take for current.
710
+ const asOf = reading === 'stale' && r.snapshotAgeMs !== null
711
+ ? `<div class="asof stale">! ${esc(asOfAge(r.snapshotAgeMs))} ago</div>`
712
+ : reading === 'undated'
713
+ ? `<div class="asof stale">! undated</div>`
714
+ : '';
715
+ // An agent carries its own project, like every other node. Placement is not a promise —
716
+ // the grid wraps where the viewport says, and the fleet's sort can hand the same agent a
717
+ // different neighbour on the next poll — so nothing here points at the node beside it.
718
+ return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
719
+ <div class="dial">
720
+ <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>
721
+ <div class="val">${value}</div>
722
+ ${pulse ? `<span class="sr">a reading just landed</span>` : ''}
723
+ </div>
724
+ <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>
725
+ <div class="sub">${esc(r.name)}</div>
726
+ ${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
727
+ <div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
728
+ ${asOf}
729
+ </article>`;
730
+ }
731
+ /**
732
+ * How old a dated reading is, in the words both surfaces use.
733
+ *
734
+ * `duration` floors to whole minutes, and `--stale-after` takes seconds — so a 30s reading
735
+ * judged against a 2s threshold rendered "! 0m ago": the "!" saying past the threshold and
736
+ * the "0m" saying brand new, in the same breath. Under a minute the age stops pretending to
737
+ * be a round number.
738
+ */
739
+ const asOfAge = (ms) => (ms < 60_000 ? '<1m' : duration(ms));
740
+ /** The dial's geometry. One radius, named once, so the arithmetic below cannot drift from it. */
741
+ const DIAL_R = 30;
742
+ const DIAL_C = 2 * Math.PI * DIAL_R;
743
+ /**
744
+ * The filled part of the ring, as a fraction of the circle's real circumference.
745
+ *
746
+ * `pathLength="100"` would say the same thing in far prettier markup — "62 filled, 38 empty"
747
+ * — but it is an attribute browsers have not always honoured on basic shapes, and the way it
748
+ * fails is the one this page cannot afford: the dash array is ignored, the arc closes, and
749
+ * every session reads as a full context window. Two decimals is well under a pixel at this
750
+ * radius, and it keeps the markup diffable.
751
+ */
752
+ function arc(pct) {
753
+ const filled = (Math.min(100, Math.max(0, pct)) / 100) * DIAL_C;
754
+ return (`<circle class="arc" cx="40" cy="40" r="${DIAL_R}" transform="rotate(-90 40 40)"` +
755
+ ` stroke-dasharray="${round2(filled)} ${round2(DIAL_C - filled)}"/>`);
756
+ }
757
+ const round2 = (n) => Math.round(n * 100) / 100;
490
758
  function ctxCell(r) {
491
759
  if (r.ctxPct === null) {
492
- const why = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' }[r.ctxState] ?? '';
493
- return `${dash()} <span class="dim">${esc(why)}</span>`;
760
+ return `${dash()} <span class="dim">${esc(CTX_WHY[r.ctxState] ?? '')}</span>`;
494
761
  }
495
762
  // A stale reading is still the truth — of an earlier moment. Show it, and date it, with
496
763
  // the same "!" the terminal marks it with: an age in the same grey as everything else is
@@ -501,7 +768,7 @@ function ctxCell(r) {
501
768
  // "! 0m ago" — a missing measurement as a zero, contradicting itself in the same breath
502
769
  // (the "!" says past the threshold, the "0m" says brand new). The terminal path already
503
770
  // re-checked it; the two surfaces are not allowed to disagree about the module's own rule.
504
- const asOf = r.stale && r.snapshotAgeMs !== null ? ` <span class="stale">! ${esc(duration(r.snapshotAgeMs))} ago</span>` : '';
771
+ const asOf = r.stale && r.snapshotAgeMs !== null ? ` <span class="stale">! ${esc(asOfAge(r.snapshotAgeMs))} ago</span>` : '';
505
772
  return `<span class="bar"><i style="width:${Math.min(100, r.ctxPct)}%"></i></span>${r.ctxPct}%${asOf}`;
506
773
  }
507
774
  /** A partial sum is never presented as the fleet's total. */
package/dist/server.js CHANGED
@@ -14,6 +14,11 @@ import { SOURCE_PHRASE } from './config.js';
14
14
  * failures carry it too: their text is what it quotes as the reason.
15
15
  */
16
16
  const IDENTITY = { 'x-tarmac': '1' };
17
+ /** The addresses that serve the shell, and which view each one opens on. */
18
+ const PAGES = new Map([
19
+ ['/', 'table'],
20
+ ['/map', 'map'],
21
+ ]);
17
22
  export function createFleetServer({ collect }) {
18
23
  return http.createServer(async (req, res) => {
19
24
  // Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
@@ -38,7 +43,12 @@ export function createFleetServer({ collect }) {
38
43
  const url = new URL(req.url, 'http://localhost');
39
44
  // `/live` is what the open page asks for every few seconds: the same render as `/`, minus
40
45
  // the shell. Serving the whole page there would hand the running script a copy of itself.
41
- if (url.pathname !== '/' && url.pathname !== '/live' && url.pathname !== '/api/fleet') {
46
+ //
47
+ // `/map` is the same page opened on the other view, and deliberately not `/?view=map`:
48
+ // the tabs are plain links, so the view has to be somewhere a reload and a bookmark can
49
+ // both find it. There is no second fragment — one `/live` carries both views, which is
50
+ // what keeps them from ever showing readings of different ages.
51
+ if (!PAGES.has(url.pathname) && url.pathname !== '/live' && url.pathname !== '/api/fleet') {
42
52
  res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
43
53
  res.end('not found\n');
44
54
  return;
@@ -58,7 +68,7 @@ export function createFleetServer({ collect }) {
58
68
  }
59
69
  else {
60
70
  type = 'text/html; charset=utf-8';
61
- body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet);
71
+ body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet, PAGES.get(url.pathname));
62
72
  }
63
73
  }
64
74
  catch (e) {
package/dist/sessions.js CHANGED
@@ -6,9 +6,26 @@
6
6
  // Design rule carried over from the fleet's "3rd blindness": a status we do not recognise
7
7
  // is `null`, never `false`. A release that renames `busy` must make Tarmac say "I don't
8
8
  // know", not "everything is calm" — the second is a silent outage, the first is a signal.
9
+ /**
10
+ * What each word this surface prints says about the one question the boolean asks: is this
11
+ * session working. A word that does not answer it is absent, and absent means `null` — "we do
12
+ * not know" — which is the whole point of the file.
13
+ *
14
+ * The first two arrive on a session with a process of its own; the other two are a background
15
+ * agent's `state`, which is where its word lives instead.
16
+ *
17
+ * Some words are left out ON PURPOSE rather than for want of a payload. `failed` and `stopped`
18
+ * are "not working", and that is the least interesting true thing about them; `blocked` and
19
+ * `waiting` are a session halted until a human answers something, where `false` reads as calm
20
+ * on a session that needs you and `true` as fine on one that has stopped. Unknown is the only
21
+ * bucket whose node prints the word itself, so those keep it: an amber node captioned `failed`
22
+ * says what neither boolean could.
23
+ */
9
24
  const KNOWN_STATUS = new Map([
10
25
  ['busy', true],
11
26
  ['idle', false],
27
+ ['working', true],
28
+ ['done', false],
12
29
  ]);
13
30
  /** @param text raw stdout of `claude agents --json` */
14
31
  export function parseAgents(text) {
@@ -32,7 +49,10 @@ export function parseAgents(text) {
32
49
  const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
33
50
  if (!sessionId)
34
51
  health.noSessionId += 1;
35
- const status = typeof entry.status === 'string' ? entry.status : null;
52
+ // A background agent carries no `status` at all its word is under `state`. `status`
53
+ // still wins where both are present: it comes from the agent's own process, and `state`
54
+ // is what the dispatcher believes about an agent whose process may not be on this machine.
55
+ const status = typeof entry.status === 'string' ? entry.status : typeof entry.state === 'string' ? entry.state : null;
36
56
  const busy = KNOWN_STATUS.has(status) ? KNOWN_STATUS.get(status) : null;
37
57
  if (busy === null)
38
58
  health.unknownStatus += 1;
package/dist/snapshots.js CHANGED
@@ -102,8 +102,25 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
102
102
  mtimeMs = fs.statSync(file).mtimeMs;
103
103
  payload = JSON.parse(fs.readFileSync(file, 'utf8'));
104
104
  }
105
- catch {
106
- unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
105
+ catch (e) {
106
+ // ENOENT: a name listed a moment ago that resolves to nothing now. Almost always the
107
+ // sweep, deleting a cold snapshot out of the very directory we are reading — its job,
108
+ // and a race with our own housekeeping rather than a payload we failed to parse.
109
+ // Counting it made tarmac drive its own format-drift warning (up to 2675 phantom
110
+ // unreadable on one read of a 20k directory, and `list --watch` and `serve` redraw
111
+ // often enough to be inside that window).
112
+ //
113
+ // The cost, said out loud: `statSync` follows symlinks, so a DANGLING one named like a
114
+ // snapshot is ENOENT too, and it goes silent forever — a permanent state skipped as if
115
+ // it were a passing race. Deliberate. There is no payload behind a dead link either,
116
+ // and telling the two apart (an `lstat` first) buys a warning about a file `ls` already
117
+ // shows. Note it is the opposite call from `reap.ts:75`, which lstats PRECISELY so a
118
+ // dead link is not ENOENT: it deletes, and `unlink` takes a link away just fine. Reader
119
+ // and reaper ask different questions of the same shape.
120
+ //
121
+ // ENOENT only. A file we were not ALLOWED to open still counts, and must.
122
+ if (e.code !== 'ENOENT')
123
+ unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
107
124
  continue;
108
125
  }
109
126
  const t = extractTelemetry(payload);