@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/schema.js ADDED
@@ -0,0 +1,87 @@
1
+ // P4 — the schema guard: what tarmac has actually looked at, versus what it is being fed.
2
+ //
3
+ // Every field this tool reads was OBSERVED on a Claude Code build and frozen in `fixtures/`;
4
+ // none of it is promised by a published schema. The defences already in place fire once a
5
+ // field has broken — `ctxState: 'drift'` when a key is gone, `schemaBroken` when every
6
+ // snapshot drifted. They are the alarm. This is the smoke detector: a build nobody has ever
7
+ // captured is a reason to LOOK, never a reason to stop reporting, and never a reason to exit
8
+ // non-zero.
9
+ //
10
+ // It judges EVERY distinct version in flight, not the newest one. A fleet is normally
11
+ // running two builds at once — Claude Code updates itself, sessions live for days — and the
12
+ // straggler is precisely the session on a shape nobody captured. A first cut compared only
13
+ // the highest version seen, which meant one surviving session on a known build vouched for
14
+ // six running an unknown one; a release that merely DROPPED `version` was silent that way.
15
+ //
16
+ // Membership is exact string equality: `2.1.226-rc.1` is not `2.1.226`, and a prerelease
17
+ // must not inherit the silence of the release it prefixes.
18
+ //
19
+ // Known blind spot: the version is read off the statusline payload, so a machine with no
20
+ // statusline chained gives this guard nothing to judge — including about `claude agents
21
+ // --json`, which carries no version of its own. That fleet gets the "chained on 0/N"
22
+ // warning instead, which is about installation, not about schema.
23
+ /**
24
+ * The Claude Code versions whose payloads are frozen in `fixtures/` — and therefore the
25
+ * only ones any of this tool's field names have been seen to be true of.
26
+ *
27
+ * Baked into src on purpose: only `dist/` is published, so a released tarmac has no
28
+ * `fixtures/` to read at runtime. `test/schema.test.ts` compares this list against the
29
+ * directory, so the two cannot drift apart in the repo.
30
+ */
31
+ export const CHECKED_VERSIONS = {
32
+ statusline: ['2.1.220', '2.1.226'],
33
+ agents: ['2.1.226'],
34
+ };
35
+ const SURFACE_LABEL = {
36
+ statusline: 'statusline payload',
37
+ agents: '`claude agents --json`',
38
+ };
39
+ /** Where a user of a published install can actually go — the script in scripts/ is not one. */
40
+ const ISSUES_URL = 'https://github.com/adrrr/tarmac/issues';
41
+ /**
42
+ * @param seen the `version` each live snapshot reported — `null` for the ones that had no
43
+ * such key. Order only affects the order things are named in.
44
+ *
45
+ * The version comes from the statusline payload, which is written by the Claude Code that
46
+ * runs the session — not from `claude --version`, which would describe the binary on PATH
47
+ * and cost a subprocess on every collect. So this reports on the builds actually OBSERVED
48
+ * writing to the fleet.
49
+ */
50
+ export function guardVersions(seen) {
51
+ if (seen.length === 0)
52
+ return { state: 'nothing', versions: [], noVersion: 0, unchecked: [] };
53
+ const versions = [...new Set(seen.filter((v) => typeof v === 'string' && v !== ''))];
54
+ const noVersion = seen.length - seen.filter((v) => typeof v === 'string' && v !== '').length;
55
+ const unchecked = [];
56
+ for (const surface of Object.keys(CHECKED_VERSIONS)) {
57
+ const missing = versions.filter((v) => !CHECKED_VERSIONS[surface].includes(v));
58
+ if (missing.length > 0)
59
+ unchecked.push({ surface, versions: missing });
60
+ }
61
+ // A snapshot that cannot even say which build wrote it is the worse fact, so it leads —
62
+ // but the notice below still reports both.
63
+ const state = noVersion > 0 ? 'no-version' : unchecked.length > 0 ? 'unchecked' : 'ok';
64
+ return { state, versions, noVersion, unchecked };
65
+ }
66
+ /** What a human should be told, or `null` when there is nothing worth saying. */
67
+ export function schemaNotice(guard) {
68
+ if (guard.state === 'ok' || guard.state === 'nothing')
69
+ return null;
70
+ const said = [];
71
+ if (guard.noVersion > 0) {
72
+ const total = guard.noVersion + guard.versions.length;
73
+ said.push(`${guard.noVersion} of ${total} statusline payloads carry no \`version\` — the key tarmac checks its fixtures against is gone on those sessions. ` +
74
+ 'That is drift in itself: the readings are still whatever Claude Code sent, but nothing can be checked against a shape anyone has seen.');
75
+ }
76
+ if (guard.unchecked.length > 0) {
77
+ const surfaces = guard.unchecked
78
+ .map((u) => `${SURFACE_LABEL[u.surface]} — ${u.versions.join(', ')} (checked ${CHECKED_VERSIONS[u.surface].join(', ')})`)
79
+ .join('; ');
80
+ said.push(`Claude Code payload shapes that have never been checked: ${surfaces}.`);
81
+ }
82
+ // The advice has to be doable by whoever is reading it — someone who installed with npx
83
+ // and has no scripts/ directory. Capturing a fixture is a maintainer's move, documented
84
+ // in the README, not something to send a user looking for.
85
+ said.push(`Nothing is blocked and no reading is hidden; if a column starts coming up empty, update tarmac or report it at ${ISSUES_URL}.`);
86
+ return said.join(' ');
87
+ }
package/dist/server.js ADDED
@@ -0,0 +1,80 @@
1
+ // P3 — the local dashboard. node:http, localhost, no dependency.
2
+ //
3
+ // The collector is injected so the server can be exercised without spawning `claude`, and
4
+ // so a read-only snapshot directory (the fleet's own, for the demo) is just a parameter.
5
+ import http from 'node:http';
6
+ import { reason, renderLive, renderPage } from './render.js';
7
+ /**
8
+ * On every answer, including the refusals and the 500s. The page swaps what this port returns
9
+ * into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
10
+ * process that takes the port after `tarmac serve` exits, or a proxy standing in front of it,
11
+ * answers 200 with whatever it likes — `<img src=x onerror=…>` included — into a page the
12
+ * user opened themselves. The page refuses to swap anything that does not carry this, so the
13
+ * failures carry it too: their text is what it quotes as the reason.
14
+ */
15
+ const IDENTITY = { 'x-tarmac': '1' };
16
+ export function createFleetServer({ collect }) {
17
+ return http.createServer(async (req, res) => {
18
+ // Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
19
+ // from reading /api/fleet — which carries cwd paths, session ids and costs.
20
+ if (!isLoopbackHost(req.headers.host)) {
21
+ res.writeHead(403, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
22
+ res.end('tarmac serves loopback hosts only\n');
23
+ return;
24
+ }
25
+ // The Host check stops another origin READING this port; it does not stop one poking it.
26
+ // Any page the user visits can `fetch(…, {mode:'no-cors'})` here as fast as it likes —
27
+ // CORS hides the answer, but each request still spawns `claude agents --json`. Browsers
28
+ // label their own requests, so a label that says cross-site is refused before anything is
29
+ // spawned; a client that sends no label (curl, a script) is left alone.
30
+ const site = req.headers['sec-fetch-site'];
31
+ if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') {
32
+ res.writeHead(403, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
33
+ res.end('tarmac serves same-origin requests only\n');
34
+ return;
35
+ }
36
+ // `url` is always set on a server-side request; the assertion adds no branch.
37
+ const url = new URL(req.url, 'http://localhost');
38
+ // `/live` is what the open page asks for every few seconds: the same render as `/`, minus
39
+ // the shell. Serving the whole page there would hand the running script a copy of itself.
40
+ if (url.pathname !== '/' && url.pathname !== '/live' && url.pathname !== '/api/fleet') {
41
+ res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
42
+ res.end('not found\n');
43
+ return;
44
+ }
45
+ // Read AND render inside the guard, and send nothing until there is something to send.
46
+ // Writing the 200 first and rendering after made the collector's failure a 500 and the
47
+ // renderer's failure a dead daemon: the headers were already on the wire, the throw
48
+ // became an unhandled rejection, and `tarmac serve` — which runs unattended for hours —
49
+ // left the browser holding an answer that never came.
50
+ let type;
51
+ let body;
52
+ try {
53
+ const fleet = await collect();
54
+ if (url.pathname === '/api/fleet') {
55
+ type = 'application/json; charset=utf-8';
56
+ body = JSON.stringify(fleet, null, 2);
57
+ }
58
+ else {
59
+ type = 'text/html; charset=utf-8';
60
+ body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet);
61
+ }
62
+ }
63
+ catch (e) {
64
+ // Say why. A dashboard that goes blank when its source breaks teaches nothing.
65
+ res.writeHead(500, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
66
+ res.end(`tarmac could not read the fleet:\n${reason(e)}\n`);
67
+ return;
68
+ }
69
+ // A page whose entire claim is freshness must not be served from a cache: a restored tab
70
+ // re-running the script over stale HTML would re-stamp it "updated just now".
71
+ res.writeHead(200, { ...IDENTITY, 'content-type': type, 'cache-control': 'no-store' });
72
+ res.end(body);
73
+ });
74
+ }
75
+ function isLoopbackHost(host) {
76
+ if (!host)
77
+ return false;
78
+ const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
79
+ return name === 'localhost' || name === '127.0.0.1' || name === '::1';
80
+ }
@@ -0,0 +1,51 @@
1
+ // P1 — Contractual session discovery.
2
+ //
3
+ // Single source: `claude agents --json`, a CLI surface Claude Code publishes on purpose.
4
+ // Tarmac parses NO internal format here: no ~/.claude/projects/*.jsonl, no tmux pane pixels.
5
+ //
6
+ // Design rule carried over from the fleet's "3rd blindness": a status we do not recognise
7
+ // is `null`, never `false`. A release that renames `busy` must make Tarmac say "I don't
8
+ // know", not "everything is calm" — the second is a silent outage, the first is a signal.
9
+ const KNOWN_STATUS = new Map([
10
+ ['busy', true],
11
+ ['idle', false],
12
+ ]);
13
+ /** @param text raw stdout of `claude agents --json` */
14
+ export function parseAgents(text) {
15
+ let raw;
16
+ try {
17
+ raw = JSON.parse(text);
18
+ }
19
+ catch {
20
+ throw new Error('`claude agents --json` output is not valid JSON');
21
+ }
22
+ if (!Array.isArray(raw)) {
23
+ throw new Error('`claude agents --json`: expected a JSON array');
24
+ }
25
+ const health = { seen: raw.length, noSessionId: 0, unknownStatus: 0 };
26
+ const sessions = [];
27
+ for (const entry of raw) {
28
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
29
+ health.noSessionId += 1;
30
+ continue;
31
+ }
32
+ const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
33
+ if (!sessionId)
34
+ health.noSessionId += 1;
35
+ const status = typeof entry.status === 'string' ? entry.status : null;
36
+ const busy = KNOWN_STATUS.has(status) ? KNOWN_STATUS.get(status) : null;
37
+ if (busy === null)
38
+ health.unknownStatus += 1;
39
+ sessions.push({
40
+ sessionId,
41
+ pid: typeof entry.pid === 'number' ? entry.pid : null,
42
+ cwd: typeof entry.cwd === 'string' ? entry.cwd : null,
43
+ name: typeof entry.name === 'string' ? entry.name : null,
44
+ kind: typeof entry.kind === 'string' ? entry.kind : null,
45
+ startedAt: typeof entry.startedAt === 'number' ? entry.startedAt : null,
46
+ status,
47
+ busy,
48
+ });
49
+ }
50
+ return { sessions, health };
51
+ }
@@ -0,0 +1,65 @@
1
+ // P2 — pure settings surgery. No filesystem here, so every branch is cheap to test.
2
+ //
3
+ // Two promises the product makes about `settings.json`:
4
+ // 1. NON-DESTRUCTIVE — an existing statusLine is wrapped (still rendered), never replaced.
5
+ // 2. REVERSIBLE — what we removed is handed back to the caller so it can be stored and
6
+ // put back verbatim.
7
+ //
8
+ // Anything we do not understand is REFUSED, not overwritten. A statusLine shape we cannot
9
+ // chain is a user's working display; guessing would break their terminal.
10
+ // Identity of "is this command OUR wrapper?" is NOT string equality. `/tmp/x` and
11
+ // `/private/tmp/x` are the same file on macOS; so are a bind mount and a firmlink. Getting
12
+ // this wrong makes tarmac wrap its own wrapper — an unbounded recursion at every TUI frame,
13
+ // with the user's real statusline erased from the only file that named it. The comparator
14
+ // is injected because this module stays pure; `install.ts` passes a filesystem-aware one.
15
+ const stringEquality = (a, b) => a === b;
16
+ /**
17
+ * @param settings parsed settings.json (may be {})
18
+ * @param wrapperPath absolute path to the tarmac wrapper
19
+ */
20
+ export function chainStatusLine(settings, wrapperPath, { isSameCommand = stringEquality, commandSpelling } = {}) {
21
+ const next = structuredClone(settings ?? {});
22
+ const current = next.statusLine;
23
+ if (isOurs(current, wrapperPath, isSameCommand)) {
24
+ return { settings: next, previous: null, alreadyInstalled: true };
25
+ }
26
+ let previous = null;
27
+ if (current !== undefined && current !== null) {
28
+ if (!isChainableCommand(current)) {
29
+ throw new Error('refusing to replace an unrecognised statusLine (expected {type:"command", command:"…"})');
30
+ }
31
+ previous = structuredClone(current);
32
+ }
33
+ // Swap ONLY the command. Every other key (`padding: 0` is flush-left, and more may come)
34
+ // is the user's display configuration: dropping it changes what they see, which is the
35
+ // one thing chaining promises not to do.
36
+ next.statusLine = { ...(previous ?? {}), type: 'command', command: commandSpelling ?? wrapperPath };
37
+ return { settings: next, previous, alreadyInstalled: false };
38
+ }
39
+ /**
40
+ * @param settings parsed settings.json as it stands now
41
+ * @param previous the statusLine recorded at install time
42
+ * @param wrapperPath the wrapper we installed
43
+ */
44
+ export function unchainStatusLine(settings, previous, wrapperPath, { isSameCommand = stringEquality } = {}) {
45
+ const next = structuredClone(settings ?? {});
46
+ // Someone else owns the statusLine now — hands off. The caller MUST learn that nothing
47
+ // was undone, or it deletes the wrapper that line still points at.
48
+ if (!isOurs(next.statusLine, wrapperPath, isSameCommand))
49
+ return { settings: next, restored: false };
50
+ if (previous)
51
+ next.statusLine = structuredClone(previous);
52
+ else
53
+ delete next.statusLine;
54
+ return { settings: next, restored: true };
55
+ }
56
+ function isChainableCommand(v) {
57
+ return (typeof v === 'object' &&
58
+ v !== null &&
59
+ v.type === 'command' &&
60
+ typeof v.command === 'string' &&
61
+ v.command !== '');
62
+ }
63
+ function isOurs(v, wrapperPath, isSameCommand) {
64
+ return isChainableCommand(v) && isSameCommand(v.command, wrapperPath);
65
+ }
package/dist/shell.js ADDED
@@ -0,0 +1,73 @@
1
+ // The two directions of one fact: `statusLine.command` is shell source.
2
+ //
3
+ // Claude Code documents that field as running in a shell, so tarmac writes a PATH into it
4
+ // (which must survive word splitting) and later reads a path back out of it (to answer "is
5
+ // this command my own wrapper?"). Those are inverse operations, and when they drifted apart
6
+ // — quoting that escaped an apostrophe, reading that only stripped the outer quotes — a home
7
+ // named `od d's` stopped being recognised as its own install: the wrapper chained itself,
8
+ // and one frame forked until the process table gave up. Written as a pair, tested as a pair.
9
+ /** Characters a shell leaves alone. Anything else has to be quoted to survive. */
10
+ const SHELL_SAFE = /^[A-Za-z0-9_@%+:,.\/-]+$/;
11
+ /** `s` as shell source that evaluates back to exactly `s`. Quoted only when it needs to be. */
12
+ export function quoteArg(s) {
13
+ return SHELL_SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
14
+ }
15
+ /**
16
+ * The first word of a command line, with quoting removed — i.e. the file it runs. Arguments,
17
+ * pipes and redirects are none of our business; the file is what identity is decided on.
18
+ *
19
+ * @returns `''` when the line cannot be parsed (an unbalanced quote). A command we cannot
20
+ * read is not one we may guess at: this answer decides whether tarmac deletes a
21
+ * wrapper, and half a quoted string could name any file at all.
22
+ */
23
+ export function firstWord(command) {
24
+ const s = command.trim();
25
+ let out = '';
26
+ let i = 0;
27
+ while (i < s.length) {
28
+ const c = s[i];
29
+ if (c === ' ' || c === '\t' || c === '\n')
30
+ break;
31
+ if (c === "'") {
32
+ const end = s.indexOf("'", i + 1);
33
+ if (end === -1)
34
+ return '';
35
+ out += s.slice(i + 1, end);
36
+ i = end + 1;
37
+ }
38
+ else if (c === '"') {
39
+ const closed = readDoubleQuoted(s, i + 1);
40
+ if (closed === null)
41
+ return '';
42
+ out += closed.text;
43
+ i = closed.next;
44
+ }
45
+ else if (c === '\\') {
46
+ if (i + 1 >= s.length)
47
+ return '';
48
+ out += s[i + 1];
49
+ i += 2;
50
+ }
51
+ else {
52
+ out += c;
53
+ i += 1;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /** Inside double quotes only `\` before `"`, `\`, `$` and a backtick is an escape. */
59
+ function readDoubleQuoted(s, from) {
60
+ let text = '';
61
+ for (let i = from; i < s.length; i++) {
62
+ const c = s[i];
63
+ if (c === '"')
64
+ return { text, next: i + 1 };
65
+ if (c === '\\' && i + 1 < s.length && ['"', '\\', '$', '`'].includes(s[i + 1])) {
66
+ text += s[i + 1];
67
+ i += 1;
68
+ continue;
69
+ }
70
+ text += c;
71
+ }
72
+ return null;
73
+ }
@@ -0,0 +1,137 @@
1
+ // P3 — reading the telemetry the wrapper dropped.
2
+ //
3
+ // The snapshot is a verbatim copy of Claude Code's own statusLine payload, so this module
4
+ // reads a DOCUMENTED shape — no transcript resummation, no window size hardcoded, no regex
5
+ // over terminal pixels.
6
+ //
7
+ // The one rule everything else leans on: "no value" is never rendered as 0. `ctxPct: null`
8
+ // means "not measured"; a 0 could only ever mean "measured at 0". And "not measured" has
9
+ // two opposite causes that must not be confused:
10
+ // fresh — the key is present and null: a session that has taken no turn yet. Normal,
11
+ // transient, true of a whole fleet for a few minutes after a nightly recycle.
12
+ // drift — the key is gone or changed type: a Claude Code release moved the schema and
13
+ // the context reading of the entire fleet just died, snapshots still flowing.
14
+ // The discriminant is the PRESENCE of the key, never its value.
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ export function extractTelemetry(payload) {
18
+ const p = payload;
19
+ const cw = p?.context_window;
20
+ let ctxState = 'drift';
21
+ let ctxPct = null;
22
+ if (cw && typeof cw === 'object' && !Array.isArray(cw) && 'used_percentage' in cw) {
23
+ const v = cw.used_percentage;
24
+ if (v === null)
25
+ ctxState = 'fresh';
26
+ // A number by TYPE is not yet a percentage. `1e999` is legal JSON and parses to
27
+ // `Infinity`, which printed as "Infinity%" in the terminal and as a bar clamped to 100%
28
+ // on the page; a negative one reached that bar as `width:-3%`, an element that renders as
29
+ // nothing at all beside a confident "-3%". Both are values no reading can have, so they
30
+ // are treated as what they are — a shape that moved — rather than shown to anyone.
31
+ else if (Number.isFinite(v) && v >= 0 && v <= 100) {
32
+ ctxState = 'ok';
33
+ ctxPct = Math.floor(v);
34
+ }
35
+ }
36
+ // Same rule as above, one level down: sum only what is really a number, and return null
37
+ // when NONE of the four expected keys is one. Coercing absent keys to 0 would turn a
38
+ // renamed schema into a confident `ctxTokens: 0` sitting next to a healthy `ctxState`.
39
+ const ctxTokens = sumUsage(cw?.current_usage);
40
+ return {
41
+ sessionId: str(p?.session_id),
42
+ ctxState,
43
+ ctxPct,
44
+ ctxTokens,
45
+ ctxWindow: typeof cw?.context_window_size === 'number' ? cw.context_window_size : null,
46
+ model: str(p?.model?.display_name),
47
+ modelId: str(p?.model?.id),
48
+ effort: str(p?.effort?.level),
49
+ costUsd: typeof p?.cost?.total_cost_usd === 'number' ? p.cost.total_cost_usd : null,
50
+ ccVersion: str(p?.version),
51
+ rateLimits: p?.rate_limits && typeof p.rate_limits === 'object' ? p.rate_limits : null,
52
+ };
53
+ }
54
+ const USAGE_KEYS = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens'];
55
+ function sumUsage(usage) {
56
+ if (!usage || typeof usage !== 'object' || Array.isArray(usage))
57
+ return null;
58
+ let sum = 0;
59
+ let seen = false;
60
+ for (const k of USAGE_KEYS) {
61
+ const v = usage[k];
62
+ if (typeof v === 'number') {
63
+ sum += v;
64
+ seen = true;
65
+ }
66
+ }
67
+ return seen ? sum : null;
68
+ }
69
+ /**
70
+ * Read-only sweep of a snapshot directory, keyed by the session id found INSIDE each
71
+ * payload. `now` is an argument so tests can pin the clock.
72
+ *
73
+ * "Nothing there" and "I was not allowed to look" must not answer alike: the first is a
74
+ * fleet that has not been chained yet, the second is a permission bug that would otherwise
75
+ * render as "0/7 chained — run tarmac install", blaming the user for our own blindness.
76
+ */
77
+ export function readSnapshots(dir, { now = Date.now() } = {}) {
78
+ const snapshots = new Map();
79
+ let entries;
80
+ try {
81
+ entries = fs.readdirSync(dir);
82
+ }
83
+ catch (e) {
84
+ const code = e.code;
85
+ return {
86
+ snapshots,
87
+ dirError: code === 'ENOENT' ? null : `${code}: ${dir}`,
88
+ unreadable: 0,
89
+ duplicates: 0,
90
+ dirMissing: code === 'ENOENT',
91
+ };
92
+ }
93
+ let unreadable = 0;
94
+ let duplicates = 0;
95
+ for (const name of entries) {
96
+ if (!name.endsWith('.json') || name.startsWith('.'))
97
+ continue;
98
+ const file = path.join(dir, name);
99
+ let payload;
100
+ let mtimeMs;
101
+ try {
102
+ mtimeMs = fs.statSync(file).mtimeMs;
103
+ payload = JSON.parse(fs.readFileSync(file, 'utf8'));
104
+ }
105
+ catch {
106
+ unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
107
+ continue;
108
+ }
109
+ const t = extractTelemetry(payload);
110
+ if (!t.sessionId) {
111
+ unreadable += 1;
112
+ continue;
113
+ }
114
+ const snapshot = { ...t, ageMs: Math.round(now - mtimeMs), file };
115
+ const already = snapshots.get(t.sessionId);
116
+ if (already)
117
+ duplicates += 1;
118
+ snapshots.set(t.sessionId, already ? preferred(already, snapshot) : snapshot);
119
+ }
120
+ return { snapshots, dirError: null, unreadable, duplicates, dirMissing: false };
121
+ }
122
+ /**
123
+ * Which of two snapshots claiming one session a reader is shown.
124
+ *
125
+ * Freshest first — but "freshest" decides nothing when the two carry the same mtime, and
126
+ * that is precisely the case this rule exists for: `cp -p`, `rsync -a` and `tar -x` all
127
+ * preserve mtime, so a snapshot copied between directories arrives as a perfect twin. The
128
+ * loser of a tie was then whichever one readdir happened to hand over first — an order no
129
+ * filesystem promises (ext4 with dir_index and APFS both answer in hash order), so the SAME
130
+ * two files could show a different number on two machines, silently.
131
+ *
132
+ * The filename breaks the tie because it is the only thing left that both files carry and
133
+ * neither shares. Which one it picks matters far less than that it picks the same one every
134
+ * time, everywhere.
135
+ */
136
+ export const preferred = (a, b) => a.ageMs !== b.ageMs ? (a.ageMs < b.ageMs ? a : b) : a.file <= b.file ? a : b;
137
+ const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
package/dist/watch.js ADDED
@@ -0,0 +1,49 @@
1
+ // P3 — `tarmac list --watch`: the terminal's half of the live view.
2
+ //
3
+ // The loop holds one piece of state the one-shot `list` never needed — the last fleet it
4
+ // managed to read — because that is what makes an honest failure possible. When `claude`
5
+ // stops answering, the table stays (it is still true, of an earlier moment), the reason is
6
+ // printed above it, and the age underneath it keeps climbing. A watch that simply redrew the
7
+ // same numbers would be the frozen dashboard, in a smaller window.
8
+ //
9
+ // Clock and sleeping are injected: a suite that waits five real seconds per frame is a suite
10
+ // nobody runs.
11
+ import { reason, renderWatch, REFRESH_MS } from './render.js';
12
+ /** Home, then erase — in that order, so the scrollback above is not what gets redrawn into. */
13
+ const CLEAR = '\x1b[H\x1b[2J';
14
+ export async function runWatch({ collect, write, sleep, now, everyMs = REFRESH_MS, isTTY = false, signal, }) {
15
+ let fleet = null;
16
+ let lastOk = now();
17
+ let error = null;
18
+ const draw = () => write((isTTY ? CLEAR : '') + renderWatch({ fleet, error, ageMs: now() - lastOk, everyMs }));
19
+ while (!signal.aborted) {
20
+ try {
21
+ fleet = await collect();
22
+ lastOk = now();
23
+ error = null;
24
+ }
25
+ catch (e) {
26
+ // Kept, not thrown: one unreadable tick is a thing to report, not a reason to quit and
27
+ // take the last good reading off the screen with it. `reason` rather than `.message`
28
+ // because a non-Error rejection yields `undefined`, which is falsy — the failure line
29
+ // would not have printed badly, it would not have printed at all.
30
+ error = reason(e);
31
+ }
32
+ draw();
33
+ if (signal.aborted)
34
+ break;
35
+ // A terminal redraws while it waits, so the age on screen is never more than a second
36
+ // stale — and a collector that hangs shows a counter that has visibly stopped rather than
37
+ // a table still claiming to be current. Down a pipe there is no screen to keep current,
38
+ // and a frame a second would be nothing but noise, so the wait stays one sleep.
39
+ if (!isTTY) {
40
+ await sleep(everyMs);
41
+ continue;
42
+ }
43
+ for (let left = everyMs; left > 0 && !signal.aborted; left -= 1000) {
44
+ await sleep(Math.min(1000, left));
45
+ if (!signal.aborted)
46
+ draw();
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,121 @@
1
+ // P2 — the generated statusline wrapper.
2
+ //
3
+ // Claude Code calls `statusLine.command` on EVERY frame of its TUI, passing a documented
4
+ // JSON payload on stdin. The wrapper does exactly two things:
5
+ // 1. drop that payload as-is under <snapshotDir>/<session_id>.json (the telemetry);
6
+ // 2. hand stdin to the command that was already configured, so the user's display is
7
+ // untouched.
8
+ //
9
+ // Written as POSIX sh rather than Node on purpose: this sits in the render path of every
10
+ // frame. A `node` boot per frame (~40 ms) is an order of magnitude more than a `sh` one,
11
+ // and the fleet measured the sh version at ≈ +10 ms/frame in production.
12
+ //
13
+ // POSIX means POSIX: on Debian and Ubuntu `/bin/sh` is dash, not bash. Every construct
14
+ // below is in the POSIX shell command language, and `test/portability.test.ts` runs this
15
+ // script under every POSIX shell present on the machine to keep it that way.
16
+ //
17
+ // Two invariants, both tested by running the real script:
18
+ // RULE 1 — never break the display. Missing chain, failing chain, unwritable directory:
19
+ // the status line still renders and the exit code is still 0. Telemetry loses,
20
+ // display wins, always.
21
+ // RULE 2 — never write outside the snapshot directory. `session_id` is external input
22
+ // that becomes a filename, so anything that is not UUID-shaped is REFUSED, not
23
+ // sanitised: a guessed name would be read back later as if it were certain.
24
+ /**
25
+ * Prefix of every temp file the wrapper writes, and the ONLY thing that proves tarmac
26
+ * wrote one. `.<sid>.<pid>.tmp` — what this used to emit — is a convention, not a
27
+ * signature: the fleet's own production statusline wrapper emits byte-identical names into
28
+ * a directory the docs tell you to point `--snapshots-dir` at. `src/reap.ts` builds
29
+ * its match from this constant so the writer and the deleter can never drift apart.
30
+ */
31
+ export const TEMP_PREFIX = '.tarmac-';
32
+ /**
33
+ * The line every generated wrapper carries, and the last word on "is this file one of
34
+ * ours?". Path identity answers that question only for the spellings `stat` can resolve —
35
+ * `~/…`, quoted, or written relative to somewhere else all stat to nothing, and a tarmac
36
+ * that fails to recognise itself wraps its own wrapper: unbounded recursion at every frame,
37
+ * with the real statusline erased from the only file that named it. The file says what it
38
+ * is, in itself, whatever the spelling that reached it.
39
+ */
40
+ export const WRAPPER_MARKER = 'tarmac statusline wrapper — GENERATED';
41
+ /** Single-quotes a string for POSIX sh. */
42
+ function shQuote(s) {
43
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
44
+ }
45
+ /** @returns the sh source of the wrapper */
46
+ export function renderWrapper({ snapshotDir, chainCommand }) {
47
+ return `#!/bin/sh
48
+ # ${WRAPPER_MARKER}, do not edit.
49
+ # Removed by \`tarmac uninstall\`, which also puts back the statusLine it wrapped.
50
+ TARMAC_DIR=${shQuote(snapshotDir)}
51
+ TARMAC_CHAIN=${shQuote(chainCommand ?? '')}
52
+
53
+ payload=$(cat)
54
+
55
+ # --- extract "session_id" without forking (no jq, no python: this runs every frame) ---
56
+ # The match is positional (first occurrence). If the payload ever gains a NESTED id — a
57
+ # parent or subagent block — the first one is not ours, the snapshot would be filed under
58
+ # a wrong name, and a second session would silently clobber it. Ambiguity is refused, not
59
+ # resolved by guessing: same rule as the UUID shape check below.
60
+ sid=''
61
+ rest=\${payload#*'"session_id"'}
62
+ case "\${rest}" in
63
+ *'"session_id"'*) payload_has_two_ids=1 ;;
64
+ *) payload_has_two_ids=0 ;;
65
+ esac
66
+
67
+ case "$payload" in
68
+ *'"session_id"'*)
69
+ rest=\${payload#*'"session_id"'}
70
+ rest=\${rest#*:}
71
+ case "$rest" in
72
+ *'"'*)
73
+ rest=\${rest#*'"'}
74
+ sid=\${rest%%'"'*}
75
+ ;;
76
+ esac
77
+ ;;
78
+ esac
79
+ # refuse anything that is not UUID-shaped — this value becomes a filename
80
+ case "$sid" in
81
+ ''|*[!0-9a-zA-Z-]*) sid='' ;;
82
+ esac
83
+ # refuse an ambiguous payload outright
84
+ [ "$payload_has_two_ids" = 1 ] && sid=''
85
+ if [ -n "$sid" ]; then
86
+ len=\${#sid}
87
+ if [ "$len" -lt 8 ] || [ "$len" -gt 64 ]; then sid=''; fi
88
+ fi
89
+
90
+ # --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
91
+ if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
92
+ tmp="$TARMAC_DIR/${TEMP_PREFIX}$sid.$$.tmp"
93
+ if printf '%s\\n' "$payload" > "$tmp" 2>/dev/null; then
94
+ mv -f "$tmp" "$TARMAC_DIR/$sid.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
95
+ else
96
+ rm -f "$tmp" 2>/dev/null
97
+ fi
98
+ fi
99
+
100
+ # --- hand over to the status line that was already there ---
101
+ if [ -n "$TARMAC_CHAIN" ]; then
102
+ printf '%s\\n' "$payload" | sh -c "$TARMAC_CHAIN"
103
+ else
104
+ # nothing to chain: print the model name so the line is never blank
105
+ case "$payload" in
106
+ *'"display_name"'*)
107
+ rest=\${payload#*'"display_name"'}
108
+ rest=\${rest#*:}
109
+ case "$rest" in
110
+ *'"'*)
111
+ rest=\${rest#*'"'}
112
+ printf '%s\\n' "\${rest%%'"'*}"
113
+ ;;
114
+ esac
115
+ ;;
116
+ esac
117
+ fi
118
+
119
+ exit 0
120
+ `;
121
+ }