@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/README.md +109 -47
- package/dist/cli.js +20 -5
- package/dist/collect.js +17 -9
- package/dist/fleet.js +11 -0
- package/dist/install.js +349 -15
- package/dist/map.js +110 -0
- package/dist/reap.js +9 -4
- package/dist/render.js +285 -18
- package/dist/server.js +12 -2
- package/dist/sessions.js +21 -1
- package/dist/snapshots.js +19 -2
- package/dist/wrapper.js +108 -14
- package/package.json +9 -1
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
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 =
|
|
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>—</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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|