@adrrr/tarmac 0.1.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/prompt.js ADDED
@@ -0,0 +1,29 @@
1
+ // The confirmation that replaced the spike's blanket refusal of the real HOME.
2
+ //
3
+ // It asks for a WORD, not a keystroke: `y` is the answer people give without reading, and
4
+ // what is about to change is a working terminal's status line. And it never treats silence
5
+ // as consent — a closed pipe answers nothing, so a non-TTY stdin is refused outright and
6
+ // scripts have to say `--yes` on the command line, deliberately, in writing.
7
+ import readline from 'node:readline';
8
+ /** @throws when there is no way to ask and no `--yes` — never returns a guessed answer. */
9
+ export async function confirmTyped({ word, input, output, isTTY, yes }) {
10
+ if (yes)
11
+ return true;
12
+ if (!isTTY) {
13
+ throw new Error(`stdin is not a terminal, so nothing can be confirmed — pass --yes to ${word} without asking`);
14
+ }
15
+ output.write(`Type "${word}" to proceed, anything else to abort: `);
16
+ return (await readLine(input)) === word;
17
+ }
18
+ /** The first line, trimmed. A stream that ends without one has said nothing at all. */
19
+ async function readLine(input) {
20
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
21
+ try {
22
+ for await (const line of rl)
23
+ return line.trim();
24
+ return '';
25
+ }
26
+ finally {
27
+ rl.close();
28
+ }
29
+ }
package/dist/reap.js ADDED
@@ -0,0 +1,64 @@
1
+ // Wrapper hygiene — the one thing tarmac deletes.
2
+ //
3
+ // The generated wrapper writes `<dir>/.tarmac-<session_id>.<pid>.tmp` and renames it over
4
+ // `<session_id>.json`, so the snapshot a reader sees is never half-written. Kill the
5
+ // terminal in the gap between the two and the temp file survives its process — one per
6
+ // interrupted frame, forever.
7
+ //
8
+ // Two rules, and the first one is the whole design:
9
+ // • only what we WROTE — not "what looks like something we might have written".
10
+ // `.<sid>.<pid>.tmp` is the temp-file convention of half the world, including the
11
+ // production statusline script this tool is documented as being pointed at. So the
12
+ // wrapper signs its temp files with `TEMP_PREFIX`, and the match below is built from
13
+ // that same constant: writer and deleter cannot drift apart.
14
+ // • only what is finished. A frame takes milliseconds; anything recent may be a write
15
+ // in flight, and deleting it would be the reaper causing the corruption it prevents.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { TEMP_PREFIX } from './wrapper.js';
19
+ /** Exported so a test can build the same expectation from the same constant, escaped. */
20
+ export const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ // `<TEMP_PREFIX><sid>.<pid>.tmp` — the sid charset and length are the ones the wrapper
22
+ // enforces before it agrees to use the value as a filename, and the pid is what `$$` emits.
23
+ const TEMP_NAME = new RegExp(`^${escapeRe(TEMP_PREFIX)}[0-9A-Za-z-]{8,64}\\.\\d+\\.tmp$`);
24
+ /** An hour is orders of magnitude beyond any real frame, and cheap to be wrong about. */
25
+ const DEFAULT_OLDER_THAN_MS = 60 * 60_000;
26
+ /**
27
+ * Best-effort, and best-effort means it never throws: hygiene must not be the reason a
28
+ * command fails. A directory we cannot read reports nothing here — `readSnapshots` is the
29
+ * one that raises that alarm, and raising it twice would only teach the user to ignore it.
30
+ *
31
+ * Known limit: the age test trusts the filesystem's clock. A file dated in the future is
32
+ * never reaped, which is the safe direction; a mount whose clock runs an hour behind could
33
+ * make a fresh temp file look stale. Both need a broken clock to reach, and the worst case
34
+ * is one lost frame — the wrapper's `mv` then fails into its existing `rm -f` branch.
35
+ */
36
+ export function reapOrphanedTemps(dir, { now = Date.now(), olderThanMs = DEFAULT_OLDER_THAN_MS } = {}) {
37
+ let entries;
38
+ try {
39
+ entries = fs.readdirSync(dir);
40
+ }
41
+ catch {
42
+ return { reaped: 0, failed: 0 };
43
+ }
44
+ let reaped = 0;
45
+ let failed = 0;
46
+ for (const name of entries) {
47
+ if (!TEMP_NAME.test(name))
48
+ continue;
49
+ const file = path.join(dir, name);
50
+ try {
51
+ // `lstat`, not `stat`: `unlink` removes the link, so the link's own age is the one
52
+ // that decides. Following the target made a dangling symlink throw ENOENT and get
53
+ // reported as "could not remove" — a false alarm about a file unlink handles fine.
54
+ if (now - fs.lstatSync(file).mtimeMs < olderThanMs)
55
+ continue;
56
+ fs.unlinkSync(file);
57
+ reaped += 1;
58
+ }
59
+ catch {
60
+ failed += 1;
61
+ }
62
+ }
63
+ return { reaped, failed };
64
+ }
package/dist/render.js ADDED
@@ -0,0 +1,524 @@
1
+ // P3 — the renderers. Everything a human reads comes out of this module: the terminal
2
+ // table, the install plan, and the dashboard page. One string each, no framework, no build
3
+ // step, no external asset: an `npx` tool has no business shipping a bundler.
4
+ //
5
+ // Rendering rule that matches the data model: a missing measurement renders as an em dash.
6
+ // A dashboard that prints `0%` where it means "I could not look" is how a blind sensor
7
+ // stays invisible for days. The two surfaces say the same things in their own words — so
8
+ // they are written side by side, and the suite can reach both.
9
+ import { formatDuration } from './config.js';
10
+ import { schemaNotice } from './schema.js';
11
+ /**
12
+ * The other thing this module renders: the plan a user consents to before install or
13
+ * uninstall touches their settings.json. Everything the decision rests on has to be here —
14
+ * the file, both values of `statusLine`, the way back — because what is not printed cannot
15
+ * be consented to. Terminal text, no colour: this is read once, under a prompt.
16
+ */
17
+ export function renderPlan(plan) {
18
+ const where = plan.isRealHome ? 'your home' : plan.home;
19
+ const rows = [
20
+ ['file', plan.settings],
21
+ ...(plan.writes ? [['↳ really', plan.writes]] : []),
22
+ ['statusLine now', plan.before ?? '(none)'],
23
+ ['statusLine next', plan.after ?? '(removed)'],
24
+ ];
25
+ if (plan.action === 'install') {
26
+ if (plan.chained !== null)
27
+ rows.push(['↳ which calls', `${plan.chained} (your display is unchanged)`]);
28
+ if (plan.alreadyInstalled)
29
+ rows.push(['note', 'already installed — the wrapper is regenerated, settings.json is left alone']);
30
+ }
31
+ else {
32
+ rows.push(['restore', `${plan.mode} — ${restoreMeaning(plan.mode)}`]);
33
+ }
34
+ rows.push(['undo', plan.undo]);
35
+ const w = Math.max(...rows.map(([label]) => label.length));
36
+ return (`tarmac ${plan.action} — ${where}\n\n` +
37
+ rows.map(([label, value]) => ` ${label.padEnd(w)} ${value}\n`).join('') +
38
+ '\n');
39
+ }
40
+ /** What each restore mode means, in the words the plan and the report both use. */
41
+ export const restoreMeaning = (mode) => RESTORE_MEANING[mode];
42
+ const RESTORE_MEANING = {
43
+ bytes: 'the settings.json you had, back byte for byte',
44
+ surgical: 'only the statusLine key goes back; your later edits are kept',
45
+ absent: 'settings.json is removed — there was none before install',
46
+ foreign: 'the statusLine is someone else\'s now, so nothing is restored and nothing is deleted',
47
+ };
48
+ /**
49
+ * What this run decided, and on whose authority. `serve` prints it once at startup because
50
+ * it then runs unattended for hours: a threshold or a directory whose origin is invisible is
51
+ * one nobody can go and correct — and pointing at an empty snapshots directory looks exactly
52
+ * like a fleet with no statusline chained.
53
+ */
54
+ export function renderSettings(config, configFile) {
55
+ const rows = [
56
+ ['freshness', formatDuration(config.staleAfterMs.value), config.staleAfterMs.source],
57
+ ['port', String(config.port.value), config.port.source],
58
+ ['snapshots', config.snapshotsDir.value, config.snapshotsDir.source],
59
+ ];
60
+ // Only the LABEL column is padded. Padding the values aligned the sources against the
61
+ // snapshots path, which is absolute — pushing the one word that says where a value came
62
+ // from past the edge of an 80-column terminal.
63
+ return (`tarmac settings — flag > env > file > default\n` +
64
+ rows.map(([label, value, source]) => ` ${label.padEnd(9)} ${value} (${source})\n`).join('') +
65
+ ` file ${configFile}\n`);
66
+ }
67
+ /** The one-shot `tarmac list` view: fixed-width columns, then everything we could not see. */
68
+ export function renderTable({ rows, health }) {
69
+ const head = ['PROJECT', 'STATE', 'CTX', 'AS OF', 'MODEL', 'EFFORT', 'COST', 'UP'];
70
+ const body = rows.map((r) => [
71
+ r.project ?? '—',
72
+ r.busy === true ? 'busy' : r.busy === false ? 'idle' : `?${r.status ?? ''}`,
73
+ r.ctxPct === null ? `— ${r.ctxState}` : `${r.ctxPct}%`,
74
+ // The age of the reading, never implied to be "now".
75
+ r.snapshotAgeMs === null ? '—' : ahead(r) ? '— ahead' : `${age(r.snapshotAgeMs)}${r.stale ? ' !' : ''}`,
76
+ r.model ?? '—',
77
+ r.effort ?? '—',
78
+ r.costUsd === null ? '—' : `$${r.costUsd.toFixed(2)}`,
79
+ r.uptimeMs === null ? '—' : `${Math.round(r.uptimeMs / 3600000)}h`,
80
+ ]);
81
+ const w = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
82
+ const line = (cells) => cells.map((c, i) => c.padEnd(w[i])).join(' ').trimEnd();
83
+ const warns = [];
84
+ if (health.noSessionId > 0)
85
+ warns.push(`! ${health.noSessionId}/${health.discovered} discovered sessions carry no sessionId — schema may have moved`);
86
+ // Snapshots ARRIVED and could not be keyed — a payload with no `session_id`, or one no
87
+ // parser could read. It leads the coverage line below because it is the CAUSE of it: a
88
+ // fleet that reads as unchained while its statusline is writing every frame is a schema
89
+ // change, and "run tarmac install" is advice for the opposite problem.
90
+ if ((health.snapshotsUnreadable ?? 0) > 0)
91
+ warns.push(`! ${health.snapshotsUnreadable} snapshot file(s) present but unreadable — schema may have moved, check for a newer tarmac`);
92
+ if ((health.snapshotsDuplicates ?? 0) > 0)
93
+ warns.push(`! ${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest was kept`);
94
+ // Covers both "not allowed to look" and "there is nothing there to look at", so the words
95
+ // have to fit an errno as well as a path that points nowhere.
96
+ if (health.snapshotsError)
97
+ warns.push(`! snapshots unavailable — ${health.snapshotsError}`);
98
+ else if (health.schemaBroken)
99
+ warns.push('! every snapshot drifted — the statusline payload schema moved');
100
+ else if (health.covered < health.sessions)
101
+ warns.push(`! statusline chained on ${health.covered}/${health.sessions} sessions`);
102
+ if (health.stale > 0)
103
+ warns.push(`! ${health.stale} reading(s) marked "!" are older than ${formatDuration(health.staleAfterMs)} (--stale-after)`);
104
+ const skewed = rows.filter(ahead).length;
105
+ if (skewed > 0)
106
+ warns.push(`! ${skewed} reading(s) are dated in the future — ${SKEW}`);
107
+ if (health.unknownStatus > 0)
108
+ warns.push(`! ${health.unknownStatus} session(s) report an unknown status`);
109
+ // Last, and never instead of anything above: this one is a heads-up, not a fault.
110
+ const schema = schemaNotice(health.schemaGuard);
111
+ if (schema)
112
+ warns.push(`! ${schema}`);
113
+ const total = health.costUsd === null ? 'cost —' : `$${health.costUsd.toFixed(2)}${costQualifier(health)}`;
114
+ return ([line(head), ...body.map(line)].join('\n') +
115
+ '\n' +
116
+ (warns.length ? '\n' + warns.join('\n') + '\n' : '') +
117
+ `\n${health.sessions} sessions · ${health.busy} busy · ${total}\n`);
118
+ }
119
+ /**
120
+ * A snapshot dated AFTER the clock we are reading it with — a mount whose time runs ahead, an
121
+ * NTP correction between the write and the read. Its age is not a small number, it is not a
122
+ * number at all, and `age()` rounded it to "0m": the freshest reading a column can show, for
123
+ * the one file whose freshness is unknowable. `reap.ts` already refuses to judge these files;
124
+ * both renderers refuse to date them.
125
+ */
126
+ const ahead = (r) => r.snapshotAgeMs !== null && r.snapshotAgeMs < 0;
127
+ const SKEW = "the snapshot's clock is ahead of this one, so how old the reading is cannot be told";
128
+ // Rounded, and deliberately not `duration()` below: the table trades precision for width
129
+ // ("4h", not "3h"), the page has room to floor and be exact.
130
+ function age(ms) {
131
+ const m = Math.round(ms / 60000);
132
+ if (m < 60)
133
+ return `${m}m`;
134
+ const h = Math.round(m / 60);
135
+ return h < 48 ? `${h}h` : `${Math.round(h / 24)}d`;
136
+ }
137
+ /**
138
+ * Everything on the page that a refresh replaces: the fleet's numbers, its warnings, its
139
+ * rows. The shell around it — style, script, the title — never changes, so it is rendered
140
+ * once and left alone.
141
+ *
142
+ * This is one function and not two on purpose. The rules that make a reading honest (a dash
143
+ * where nothing was measured, an age next to a dated one, a warning that names what tarmac
144
+ * could not see) are hard-won and tested; re-deriving them in browser JavaScript to redraw a
145
+ * polled row would put the second copy somewhere this suite cannot reach.
146
+ */
147
+ export function renderLive({ rows, health }) {
148
+ const warnings = [];
149
+ if (health.noSessionId > 0) {
150
+ // Never "no sessions found" when discovery DID find some it could not identify.
151
+ warnings.push(`${health.noSessionId} of ${health.discovered} discovered session(s) carry no sessionId — tarmac cannot identify them, so no telemetry can be joined. The \`claude agents --json\` schema may have moved.`);
152
+ }
153
+ if ((health.snapshotsUnreadable ?? 0) > 0) {
154
+ // Same rule as the terminal's, in the page's words: state the drift BEFORE the coverage
155
+ // line, whose advice ("run tarmac install") is for a fleet that was never chained.
156
+ warnings.push(`${health.snapshotsUnreadable} snapshot file(s) are present but unreadable — a payload tarmac cannot key to a session cannot be joined to one. Claude Code's statusline schema may have moved; check for a newer tarmac.`);
157
+ }
158
+ if ((health.snapshotsDuplicates ?? 0) > 0) {
159
+ warnings.push(`${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest reading was kept and the other ignored. Two wrappers may be writing into the same directory.`);
160
+ }
161
+ if (health.snapshotsError) {
162
+ // A permission error is ours to report, not the user's to be blamed for.
163
+ warnings.push(`The snapshot directory could not be used — ${health.snapshotsError}. Context readings are unavailable, and this is not an install problem.`);
164
+ }
165
+ else if (health.schemaBroken) {
166
+ warnings.push(`Every snapshot drifted — Claude Code's statusline schema has probably moved. Context readings are dead until the payload shape is re-checked.`);
167
+ }
168
+ else if (health.covered < health.sessions) {
169
+ warnings.push(`Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`);
170
+ }
171
+ if (health.unknownStatus > 0) {
172
+ warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
173
+ }
174
+ if (health.stale > 0) {
175
+ 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.`);
176
+ }
177
+ const skewed = rows.filter(ahead).length;
178
+ if (skewed > 0) {
179
+ warnings.push(`${skewed} reading(s) are dated in the future — ${SKEW}. They are shown undated rather than as brand new.`);
180
+ }
181
+ const schema = schemaNotice(health.schemaGuard);
182
+ if (schema)
183
+ warnings.push(schema);
184
+ const body = rows.length === 0
185
+ ? health.noSessionId > 0
186
+ ? // Discovery DID return entries — we just could not identify them. Saying "none
187
+ // found" here would hide a schema change behind a calm, wrong answer.
188
+ `<p class="empty">No session could be identified, though ${health.noSessionId} were discovered.</p>`
189
+ : `<p class="empty">No Claude Code sessions found. Is a session running?</p>`
190
+ : `<table>
191
+ <thead><tr>
192
+ <th>Project</th><th>Session</th><th>State</th><th>Context</th><th>Model</th><th>Effort</th><th>Cost</th><th>Uptime</th>
193
+ </tr></thead>
194
+ <tbody>${rows.map(renderRow).join('')}</tbody>
195
+ </table>`;
196
+ return `<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
197
+ ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
198
+ <div class="wrap">${body}</div>`;
199
+ }
200
+ /**
201
+ * One frame of `tarmac list --watch`. It owes the reader exactly what the page owes: the
202
+ * table, when the reading in it arrived, and whether the last attempt to refresh it failed.
203
+ * The last good table stays on screen through a failure — it is still true, of an earlier
204
+ * moment — with the failure named above the age that keeps climbing underneath it.
205
+ */
206
+ export function renderWatch({ fleet, error, ageMs, everyMs }) {
207
+ const parts = [];
208
+ if (fleet)
209
+ parts.push(renderTable(fleet));
210
+ if (error)
211
+ parts.push(`! refresh failing — ${error}\n`);
212
+ parts.push(
213
+ // No fleet, no age: "updated 0s ago" before the first reading ever landed would be the
214
+ // same confident lie as a 0% context nobody measured.
215
+ `${fleet ? `updated ${ago(ageMs)} ago · ` : ''}refreshing every ${formatDuration(everyMs)} · ^C to quit\n`);
216
+ return parts.join('\n');
217
+ }
218
+ /**
219
+ * Whatever was thrown, in words a human reads.
220
+ *
221
+ * `(e as Error).message` is a promise the compiler cannot keep: anything rejected that is not
222
+ * an Error yields `undefined`, and `undefined` is FALSY — so the failure line did not print
223
+ * badly, it did not print at all, and the frame showed the last good table with nothing to
224
+ * say the refresh had stopped working. A silent failure, in the loop whose only job is to
225
+ * make that failure loud.
226
+ */
227
+ export function reason(e) {
228
+ const said = e instanceof Error ? e.message : String(e);
229
+ return said.trim() === '' || said === 'null' || said === 'undefined'
230
+ ? 'the fleet could not be read, and the failure gave no reason'
231
+ : said;
232
+ }
233
+ /**
234
+ * Seconds first — the only one of the three time formats here that has to, because it counts
235
+ * a refresh interval rather than a session's life. `age()` and `duration()` above start at
236
+ * minutes, which is right for a column and useless for a ticker. The page's script carries
237
+ * the same rule, in the same words.
238
+ */
239
+ function ago(ms) {
240
+ const s = Math.round(ms / 1000);
241
+ if (s < 60)
242
+ return `${s}s`;
243
+ const m = Math.round(s / 60);
244
+ return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
245
+ }
246
+ export function renderPage(fleet) {
247
+ return `<!doctype html>
248
+ <html lang="en"><head>
249
+ <meta charset="utf-8">
250
+ <meta name="viewport" content="width=device-width,initial-scale=1">
251
+ <title>tarmac — fleet</title>
252
+ <style>
253
+ :root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; }
254
+ @media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; } }
255
+ body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
256
+ font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
257
+ header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
258
+ h1 { font-size:1.1rem; margin:0; letter-spacing:.02em; }
259
+ .meta { color:var(--dim); font-size:.85rem; }
260
+ /* Honest, and out of the way of the fleet: three of these stacked at full padding pushed
261
+ the table below the fold on a laptop, which is its own kind of hidden. */
262
+ .warn { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:6px;
263
+ padding:.35rem .65rem; margin:.3rem 0; font-size:.8rem; line-height:1.45; }
264
+ .warn:last-of-type { margin-bottom:.9rem; }
265
+ .stale { color:var(--warn); font-weight:600; }
266
+ .wrap { overflow-x:auto; }
267
+ table { border-collapse:collapse; width:100%; min-width:44rem; }
268
+ th { text-align:left; font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em;
269
+ color:var(--dim); padding:.4rem .6rem; border-bottom:1px solid var(--line); white-space:nowrap; }
270
+ td { padding:.5rem .6rem; border-bottom:1px solid var(--line); white-space:nowrap; }
271
+ td.num { font-variant-numeric:tabular-nums; }
272
+ .dim { color:var(--dim); }
273
+ /* Shape + word + border, so the state survives a reader who cannot tell our two hues
274
+ apart, and a print. */
275
+ .pill { display:inline-block; font-size:.8rem; font-weight:600; padding:.05rem .5rem;
276
+ border:1px solid currentColor; border-radius:99px; white-space:nowrap; }
277
+ .pill.busy { color:var(--busy); }
278
+ .pill.unknown { color:var(--warn); }
279
+ .pill.idle { color:var(--dim); font-weight:400; }
280
+ /* The weight the sort deserves: busy rows carry an accent and a bold name. */
281
+ td:first-child { border-left:3px solid transparent; }
282
+ tr[data-state="busy"] td:first-child { border-left-color:var(--busy); }
283
+ tr[data-state="unknown"] td:first-child { border-left-color:var(--warn); }
284
+ tr[data-state="busy"] .project { font-weight:700; }
285
+ /* The bar reads a magnitude at a glance; the number beside it is what is authoritative.
286
+ Filling it with currentColor made it the heaviest ink on the row — a near-black slab
287
+ shouting a secondary fact. */
288
+ .bar { display:inline-block; width:4.5rem; height:.4rem; border-radius:99px; background:var(--line); vertical-align:middle; margin-right:.45rem; }
289
+ .bar > i { display:block; height:100%; border-radius:99px; background:var(--dim); }
290
+ .empty { color:var(--dim); }
291
+ .freshness { margin-left:auto; color:var(--dim); font-size:.8rem; font-variant-numeric:tabular-nums; }
292
+ .pulse { display:inline-block; width:.4rem; height:.4rem; border-radius:99px; background:var(--busy); margin-right:.4rem; vertical-align:middle; }
293
+ /* The failing state is carried by the banner's words; the dashed frame only repeats it. */
294
+ body.failing .pulse { background:var(--warn); }
295
+ body.failing #live { border:1px dashed var(--warn); border-radius:8px; padding:.5rem; }
296
+ .offline strong { white-space:nowrap; }
297
+ /* Below this the table stops being a table: one card per session, every value keeping the
298
+ name of the column it came from. Nothing is dropped — a phone that hides the context
299
+ column would be a phone that renders "not measured" as nothing at all. */
300
+ @media (max-width: 46rem) {
301
+ body { padding:1.25rem .75rem; }
302
+ .wrap { overflow-x:visible; }
303
+ table, tbody, tr, td { display:block; }
304
+ table { min-width:0; }
305
+ thead { display:none; }
306
+ tr { border:1px solid var(--line); border-left-width:3px; border-radius:8px;
307
+ padding:.35rem .7rem; margin-bottom:.6rem; }
308
+ tr[data-state="busy"] { border-left-color:var(--busy); }
309
+ tr[data-state="unknown"] { border-left-color:var(--warn); }
310
+ td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
311
+ display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
312
+ td::before { content:attr(data-label); color:var(--dim); font-size:.72rem; font-weight:600;
313
+ text-transform:uppercase; letter-spacing:.06em; flex:none; }
314
+ .v { text-align:right; }
315
+ .bar { display:none; }
316
+ }
317
+ </style>
318
+ </head><body>
319
+ <header>
320
+ <h1>tarmac</h1>
321
+ <!-- Not "updated just now". If the script never runs — a policy-injected CSP without
322
+ 'unsafe-inline', a script error — that text would stand as a permanent lie, and
323
+ <noscript> would not fire to correct it because JavaScript is enabled. The page's one
324
+ honest claim must not default to a claim at all; the first tick fills it in. -->
325
+ <span class="freshness"><span class="pulse" aria-hidden="true"></span><span id="age">updated &mdash;</span></span>
326
+ </header>
327
+ <div class="warn offline" id="offline" hidden>
328
+ <strong>&#9888; refresh failing</strong> — nothing below has moved since the time in the header.
329
+ <span id="why"></span>
330
+ </div>
331
+ <noscript><div class="warn">JavaScript is off, so this page will not refresh itself. Reload it to see the fleet now.</div></noscript>
332
+ <div id="live">${renderLive(fleet)}</div>
333
+ <script>${SCRIPT}</script>
334
+ </body></html>
335
+ `;
336
+ }
337
+ /**
338
+ * Why a poll and not the two the issue offered.
339
+ *
340
+ * A meta refresh cannot render its own failure: the moment `tarmac serve` dies, the browser
341
+ * throws the page away and puts its own error page there — and the one thing worth knowing,
342
+ * "these numbers are forty seconds old and nobody is answering", dies with it.
343
+ *
344
+ * SSE keeps a socket open per tab, and behind that socket sits `claude agents --json` on a
345
+ * server-side timer. A laptop that sleeps leaves the connection half-open and the fleet gets
346
+ * polled for a reader who is not there. A poll is the only one of the three where the client
347
+ * decides — so a hidden tab simply stops asking, and a waking one asks at once.
348
+ *
349
+ * The page therefore owns exactly two facts: when it last heard from the server, and whether
350
+ * the last attempt failed. Everything a reader interprets is rendered by `renderLive` on the
351
+ * server, where the suite can reach it.
352
+ */
353
+ export const REFRESH_MS = 5000;
354
+ /**
355
+ * How long a request may stay out before the page calls it a failure. Deliberately above the
356
+ * collector's own 15s timeout (`discoverSessions`), so a slow-but-healthy fleet always fails
357
+ * on the server side first and arrives with a real reason instead of this generic one.
358
+ */
359
+ const STALL_MS = 20000;
360
+ const SCRIPT = `
361
+ (function () {
362
+ var live = document.getElementById('live'), age = document.getElementById('age');
363
+ var off = document.getElementById('offline'), why = document.getElementById('why');
364
+ var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
365
+
366
+ function ago(ms) {
367
+ // A clock that steps backwards (an NTP correction, a laptop waking) must not produce
368
+ // "updated -3s ago". Zero is the floor.
369
+ var s = Math.round(Math.max(0, ms) / 1000);
370
+ if (s < 60) return s + 's';
371
+ var m = Math.round(s / 60);
372
+ return m < 60 ? m + 'm' : Math.round(m / 60) + 'h';
373
+ }
374
+
375
+ function fail(why_) {
376
+ failing = true;
377
+ why.textContent = why_;
378
+ off.hidden = false;
379
+ document.body.classList.toggle('failing', true);
380
+ }
381
+
382
+ // Always counting, failing or not. A number that keeps ageing in front of the reader is
383
+ // what makes a frozen table impossible to mistake for a live one.
384
+ function tick() {
385
+ // fetch has no timeout in any browser. A server that accepts the connection and never
386
+ // answers would otherwise leave the one-at-a-time guard held forever: no later poll would
387
+ // run, nothing would ever set the failing flag, and the page would sit green and quiet —
388
+ // the half-open-socket failure a poll was chosen over SSE to avoid, re-imported by the
389
+ // guard itself. The deadline sits above the collector's own 15s timeout, so a slow but
390
+ // healthy fleet always fails on the server side first, with a real reason.
391
+ if (inFlight && Date.now() - since > ${STALL_MS}) {
392
+ inFlight = false;
393
+ fail('The server took the request and did not answer within ${STALL_MS / 1000}s.');
394
+ }
395
+ age.textContent = 'updated ' + ago(Date.now() - last) + ' ago';
396
+ }
397
+
398
+ function poll() {
399
+ // One at a time. Two overlapping requests can land out of order, and the older answer
400
+ // would then overwrite the newer one and stamp itself as the fresher reading.
401
+ if (inFlight) return;
402
+ inFlight = true;
403
+ since = Date.now();
404
+ var mine = ++gen;
405
+ // An answer to a request we already gave up on must not touch the page: a newer request
406
+ // owns it by then, and letting the old one land is the out-of-order swap by another road.
407
+ var mineStill = function () { return mine === gen; };
408
+ return fetch('/live', { cache: 'no-store' }).then(function (res) {
409
+ // Before anything from this answer is read, let alone swapped into the DOM: loopback
410
+ // says where the bytes came from, not who wrote them. A process that takes the port
411
+ // after tarmac exits, or a proxy in front of it, answers 200 with whatever it likes.
412
+ // Checked ahead of the status too — a stranger's error page must not be quoted as
413
+ // tarmac's own reason.
414
+ if (!res.headers.get('X-Tarmac')) throw new Error('The answer on this port did not come from tarmac.');
415
+ return res.text().then(function (body) {
416
+ if (!res.ok) throw new Error(body.split('\\n').filter(Boolean).join(' ').slice(0, 200));
417
+ // An empty 200 is not an empty fleet. A truncated response, a proxy answering from
418
+ // an empty cache entry, and a fleet of zero sessions are three different facts, and
419
+ // only the last one has anything to say — the server always sends words, even for
420
+ // nothing. Swapping in "" would blank the table and date it "0s ago": a confident,
421
+ // freshly-stamped page claiming a fleet that was never read.
422
+ if (body.trim() === '') throw new Error('The server answered with an empty page.');
423
+ if (!mineStill()) return;
424
+ live.innerHTML = body;
425
+ last = Date.now();
426
+ failing = false;
427
+ });
428
+ }).catch(function (e) {
429
+ if (!mineStill()) return;
430
+ failing = true;
431
+ why.textContent = String((e && e.message) || e).slice(0, 200);
432
+ }).then(function () {
433
+ // Not ours to unlock: a request we were given up on must not clear a flag that a newer
434
+ // one is now holding, nor overwrite the state that newer one has set.
435
+ if (!mineStill()) return;
436
+ inFlight = false;
437
+ off.hidden = !failing;
438
+ document.body.classList.toggle('failing', failing);
439
+ tick();
440
+ });
441
+ }
442
+
443
+ setInterval(tick, 1000);
444
+ setInterval(function () { if (!document.hidden) poll(); }, ${REFRESH_MS});
445
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) poll(); });
446
+ })();
447
+ `;
448
+ /**
449
+ * The sort puts busy first, unknown next, idle last. This is where that order is given its
450
+ * weight — an accent down the row and a bold name for the ones that are working, a quiet row
451
+ * for the ones that are not.
452
+ *
453
+ * The state travels three ways at once: a shape, a word, and an attribute. Colour alone is
454
+ * no signal to a reader who cannot separate two of ours, and `data-state` is what the narrow
455
+ * layout hangs its accent on once the table has stopped being a table.
456
+ */
457
+ function renderRow(r) {
458
+ const state = stateOf(r);
459
+ const word = r.busy === true ? 'busy' : r.busy === false ? 'idle' : (r.status ?? 'unknown');
460
+ // `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
461
+ // and a value whose column has no name is a bare "—" that could mean anything.
462
+ // Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
463
+ // value right, and two sibling nodes in one cell get pushed to opposite ends of the card —
464
+ // which is how "63%" once ended up stranded in the middle of a row, under the wrong label.
465
+ return `<tr data-state="${state}">
466
+ <td data-label="Project" class="project"><span class="v">${esc(r.project)}</span></td>
467
+ <td data-label="Session" class="dim"><span class="v">${esc(r.name)}</span></td>
468
+ <td data-label="State"><span class="v"><span class="pill ${state}">${SHAPE[state]} ${esc(word)}</span></span></td>
469
+ <td data-label="Context" class="num"><span class="v">${ctxCell(r)}</span></td>
470
+ <td data-label="Model"><span class="v">${esc(r.model)}</span></td>
471
+ <td data-label="Effort" class="dim"><span class="v">${esc(r.effort)}</span></td>
472
+ <td data-label="Cost" class="num"><span class="v">${r.costUsd === null ? dash() : '$' + r.costUsd.toFixed(2)}</span></td>
473
+ <td data-label="Uptime" class="num dim"><span class="v">${r.uptimeMs === null ? dash() : esc(duration(r.uptimeMs))}</span></td>
474
+ </tr>`;
475
+ }
476
+ const stateOf = (r) => (r.busy === true ? 'busy' : r.busy === false ? 'idle' : 'unknown');
477
+ const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
478
+ function ctxCell(r) {
479
+ if (r.ctxPct === null) {
480
+ const why = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' }[r.ctxState] ?? '';
481
+ return `${dash()} <span class="dim">${esc(why)}</span>`;
482
+ }
483
+ // A stale reading is still the truth — of an earlier moment. Show it, and date it, with
484
+ // the same "!" the terminal marks it with: an age in the same grey as everything else is
485
+ // decoration, and this is the one thing the first live demo got wrong.
486
+ //
487
+ // The age is re-checked rather than asserted. `stale` and a known age are coupled in
488
+ // `buildFleet`, one module away, and a `!` assertion here rendered `duration(null)` as
489
+ // "! 0m ago" — a missing measurement as a zero, contradicting itself in the same breath
490
+ // (the "!" says past the threshold, the "0m" says brand new). The terminal path already
491
+ // re-checked it; the two surfaces are not allowed to disagree about the module's own rule.
492
+ const asOf = r.stale && r.snapshotAgeMs !== null ? ` <span class="stale">! ${esc(duration(r.snapshotAgeMs))} ago</span>` : '';
493
+ return `<span class="bar"><i style="width:${Math.min(100, r.ctxPct)}%"></i></span>${r.ctxPct}%${asOf}`;
494
+ }
495
+ /** A partial sum is never presented as the fleet's total. */
496
+ function cost(health) {
497
+ if (health.costUsd === null)
498
+ return `<span class="dim">cost —</span>`;
499
+ const partial = costQualifier(health);
500
+ if (partial === '')
501
+ return `$${health.costUsd.toFixed(2)}`;
502
+ return `$${health.costUsd.toFixed(2)} <span class="dim">${esc(partial.trim())}</span>`;
503
+ }
504
+ /**
505
+ * What the total is a total OF. The denominator is the sessions that really carry a cost —
506
+ * counting the ones that merely have a snapshot is how `$0.00` once got printed as the
507
+ * fleet's cost with no qualifier at all.
508
+ */
509
+ function costQualifier(health) {
510
+ return health.costReporting < health.sessions ? ` (${health.costReporting}/${health.sessions} reporting cost)` : '';
511
+ }
512
+ const dash = () => '<span class="dim">—</span>';
513
+ function duration(ms) {
514
+ const m = Math.floor(ms / 60000);
515
+ if (m < 60)
516
+ return `${m}m`;
517
+ const h = Math.floor(m / 60);
518
+ return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
519
+ }
520
+ function esc(v) {
521
+ if (v === null || v === undefined || v === '')
522
+ return dash();
523
+ return String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
524
+ }