@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 CHANGED
@@ -1,62 +1,111 @@
1
1
  # tarmac
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/%40adrrr%2Ftarmac)](https://www.npmjs.com/package/@adrrr/tarmac)
4
+ [![CI](https://github.com/adrrr/tarmac/actions/workflows/ci.yml/badge.svg)](https://github.com/adrrr/tarmac/actions/workflows/ci.yml)
4
5
  ![node](https://img.shields.io/node/v/%40adrrr%2Ftarmac)
5
6
  ![runtime dependencies](https://img.shields.io/badge/runtime_dependencies-0-brightgreen)
6
7
  ![license](https://img.shields.io/badge/license-MIT-blue)
7
8
 
8
9
  **Fleet observability for Claude Code.** One table for every session you have running —
9
- busy or idle, how full its context is, which model, what it has cost so far.
10
+ busy or idle, how full its context is, which model, what it has cost so far. It reads
11
+ documented surfaces only, never an internal format.
10
12
 
11
13
  ```
12
- $ npx @adrrr/tarmac list
14
+ $ npx @adrrr/tarmac
13
15
 
14
16
  PROJECT STATE CTX AS OF MODEL EFFORT COST UP
15
- apollo busy 28% 7m Fable 5 max $53.98 8h
16
- mercury-dashboard busy 27% 22m ! Fable 5 max $70.62 8h
17
- gemini idle 12% 3h ! Opus 5 high $3.14 8h
17
+ apollo busy 28% 7m Fable 5 max $41.20 8h
18
+ mercury-dashboard busy 27% 22m ! Fable 5 max $62.75 8h
19
+ gemini idle 12% 3h ! Opus 5 high $5.40 8h
18
20
  atlas idle — fresh 8h ! Opus 5 high $0.00 8h
19
21
 
20
- ! 3 reading(s) marked "!" are older than the freshness threshold
22
+ ! 3 reading(s) marked "!" are older than 10m (--stale-after)
21
23
 
22
- 4 sessions · 2 busy · $127.74
24
+ 4 sessions · 2 busy · $109.35
23
25
  ```
24
26
 
27
+ That is the table with the status line chained. Without it — nothing installed at all — the
28
+ same command still lists every session, its state and its uptime, straight from
29
+ `claude agents --json`; the context column reads `— absent`, model, effort and cost fall to
30
+ `—`, and the line under the table counts how many sessions are covered.
31
+
32
+ Node ≥ 20. **Zero runtime dependencies** — no framework, no bundler, nothing to audit.
33
+
34
+ [Manual](docs/MANUAL.md) · [Changelog](CHANGELOG.md) · [Issues](https://github.com/adrrr/tarmac/issues)
35
+
25
36
  ## Install
26
37
 
27
38
  ```bash
28
- npx @adrrr/tarmac install # chain the status line under ~/.claude, after showing you what changes
29
- npx @adrrr/tarmac list # one-shot table
30
- npx @adrrr/tarmac list --watch # the same table, redrawn every 5s until ^C
31
- npx @adrrr/tarmac serve # dashboard on http://127.0.0.1:4477
32
- npx @adrrr/tarmac uninstall # put your status line back, exactly
39
+ npx @adrrr/tarmac # one-shot fleet table
40
+ npx @adrrr/tarmac --watch # the same table, redrawn every 5s until ^C
41
+ npx @adrrr/tarmac serve # the same fleet in the browser
42
+ npx @adrrr/tarmac install # chain the status line: unlocks ctx, model, effort and cost
43
+ npx @adrrr/tarmac uninstall # hand your status line back
33
44
  ```
34
45
 
35
- Node 20. **Zero runtime dependencies** no framework, no bundler, nothing to audit.
46
+ `install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
47
+ say-so alone. It prints the whole plan first — including the exact command that undoes it —
48
+ and waits for a **typed word** (`y` is not an answer; scripts pass `--yes`, deliberately):
36
49
 
37
- `install` changes one line of `~/.claude/settings.json`, and never on your say-so alone:
38
- it prints the full plan including the exact command that undoes it — and waits for a
39
- **typed word** (`y` is not an answer; scripts pass `--yes`, deliberately). Your status
40
- line is wrapped, not replaced: the display stays byte-identical, and `uninstall` restores
41
- the original exactly.
50
+ ```
51
+ tarmac installyour home
52
+
53
+ file /Users/jane/.claude/settings.json
54
+ statusLine now ~/bin/my-line.sh
55
+ statusLine next /Users/jane/.claude/tarmac/statusline.sh
56
+ ↳ which calls ~/bin/my-line.sh (your display is unchanged)
57
+ snapshots /Users/jane/.local/state/tarmac/snapshots
58
+ undo tarmac uninstall
59
+
60
+ Type "install" to proceed, anything else to abort:
61
+ ```
62
+
63
+ A status line you already had is **wrapped, not replaced**: its display stays byte-identical,
64
+ and `uninstall` names which of its four restore modes ran — `bytes`, the usual one, puts the
65
+ original file back exactly. Note that the write re-serialises `settings.json`, so a
66
+ version-controlled one shows a formatting diff, not a one-line diff.
67
+
68
+ The only things that land under `~/.claude/` are that wrapper and the `backup.json` that
69
+ undoes it, neither of which changes at runtime: the snapshots the wrapper writes at every
70
+ frame go to `$XDG_STATE_HOME/tarmac/snapshots` (`~/.local/state/tarmac/snapshots` by
71
+ default), because `~/.claude` is a directory people commit. Coming from 0.1.x, `install`
72
+ clears the payloads an older version left in there, says how many and from where, and — when
73
+ `~/.claude` is a git repository — prints the `.gitignore` line worth adding.
42
74
 
43
75
  ## The dashboard
44
76
 
45
77
  `tarmac serve` puts the same fleet in the browser — every session a row, ages that keep
46
- climbing, and a banner the moment a refresh fails instead of a table quietly going stale:
78
+ climbing, and a banner the moment a refresh fails instead of a table quietly going stale. It
79
+ prints the settings it resolved, then the URL it got:
47
80
 
48
81
  ```
49
82
  tarmac serving http://127.0.0.1:4477
50
83
  ```
51
84
 
52
- It binds to loopback and refuses any request whose `Host` is not loopback your cwd
53
- paths and costs never leave the machine.
85
+ It binds to loopback and refuses any request whose `Host` is not loopback, or that a browser
86
+ does not mark same-origin — your cwd paths and costs never leave the machine. A busy
87
+ **default** port walks up to the next free one and says so; a port you chose yourself — flag,
88
+ environment or config file — refuses instead, because you chose it.
89
+
90
+ ### The map
91
+
92
+ The tab in the header swaps the table for the same fleet as nodes — one per session, the arc
93
+ its context, the shape by the name its state, and a single halo when a reading for it landed
94
+ moments ago. It is the same reading in the same fragment, so the two views can never disagree.
95
+
96
+ The rules the table follows, the map follows: a reading past the freshness threshold is drawn
97
+ thin, amber and dated `! 3h ago` rather than as a live one, and a percentage nobody measured
98
+ is an empty dotted dial that names which kind of nothing it is — never a ring at zero, and
99
+ never a halo, however new the file it came in. A background agent is placed beside the session
100
+ sharing its working directory, because the working directory is the only thing the two provably
101
+ share; nothing is nested, and no edge is drawn for a relationship the sources do not publish.
102
+ Details in [the manual](docs/MANUAL.md#the-map).
54
103
 
55
104
  ## Why it does not break
56
105
 
57
- Every other way to watch a Claude Code fleet reads something Claude Code never promised
58
- would stay put: transcript files, terminal panes, undocumented paths. Those tools break on
59
- an update, and — worse — they break *quietly*, reporting a calm empty fleet.
106
+ The usual ways to watch a Claude Code fleet read something Claude Code never promised would
107
+ stay put: transcript files, terminal panes, undocumented paths. Those break on an update,
108
+ and — worse — they break *quietly*, reporting a calm empty fleet.
60
109
 
61
110
  tarmac reads two things instead:
62
111
 
@@ -66,47 +115,60 @@ tarmac reads two things instead:
66
115
  | the status line payload | context %, model, effort, cost | the JSON Claude Code hands to your own `statusLine.command` on every frame — **observed, not published as a schema** |
67
116
 
68
117
  That second line is the honest caveat, and it is the reason the real defence is not
69
- immunity, it is **visible degradation**: a null renders as `— no turn yet`, a moved field
70
- as `— schema drift`, a stale reading as the value **dated**never a confident `0`. And
71
- when a session shows up on a Claude Code build no fixture covers, tarmac names that
72
- version *before* anything breaks, and keeps reporting. The full state-by-state table is
73
- in `docs/MANUAL.md`.
118
+ immunity, it is **visible degradation**. A missing measurement is never a confident `0`: it
119
+ is an em dash that names which kind of missing it is `absent` for a session no status line
120
+ ever wrote for, `fresh` for one that has taken no turn yet, `drift` for a release that moved
121
+ the payload out from under us and the fleet-wide count sits under the table, as
122
+ `! statusline chained on 0/4 sessions`. A stale reading keeps its value and gets **dated**
123
+ with a `!`. And when a session shows up on a Claude Code build no fixture covers, tarmac
124
+ names that version *before* anything breaks, and keeps reporting. The full state-by-state
125
+ table is in [`docs/MANUAL.md`](docs/MANUAL.md).
74
126
 
75
127
  ## Commands
76
128
 
77
129
  | Command | What it does | Options |
78
130
  |---|---|---|
79
- | `tarmac list` | one-shot fleet table | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
80
- | `tarmac serve` | local dashboard, `GET /` for the page, `GET /live` for the fragment it refreshes, `GET /api/fleet` for JSON | `--home`, `--port`, `--stale-after`, `--snapshots-dir`, `--claude-bin` |
131
+ | `tarmac list` | one-shot fleet table — the default, so bare `tarmac` runs it | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
132
+ | `tarmac serve` | local dashboard, `GET /` for the table, `GET /map` for the map, `GET /live` for the fragment both refresh from, `GET /api/fleet` for JSON | `--home`, `--port`, `--stale-after`, `--snapshots-dir`, `--claude-bin` |
81
133
  | `tarmac install` | chain the status line under `<home>/.claude/settings.json`, after confirmation | `--home`, `--yes` |
82
134
  | `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
83
135
 
84
136
  `--help` works everywhere. An option handed to a command that does not read it is an
85
- **error**, not something quietly ignored.
86
-
87
- Both live views tell you **when the last good reading arrived** and **whether the last
88
- refresh failed** ages keep climbing whether or not the refresh works, failures are
89
- banners with names, and a table is never thrown away for one. How each failure mode is
90
- kept from looking like health: `docs/MANUAL.md`.
137
+ **error**, not something quietly ignored — and the error names the commands it does belong
138
+ to. Both live views tell you **when the last good reading arrived** and **whether the last
139
+ refresh failed**: ages keep climbing whether or not the refresh works, failures are banners
140
+ with names, and a table is never thrown away for one.
91
141
 
92
142
  ## Configuration
93
143
 
94
144
  Three numbers are opinions, not truths, so all three are yours; everything else is
95
145
  deliberately not configurable, and all of it works with no configuration at all.
96
146
 
97
- | Setting | Flag | Environment | `~/.claude/tarmac/config.json` | Default |
147
+ | Setting | Flag | Environment | `<home>/.claude/tarmac/config.json` | Default |
98
148
  |---|---|---|---|---|
99
149
  | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` | `"staleAfterMs": 90000` | `10m` |
100
150
  | port | `--port 8080` | `TARMAC_PORT` | `"port": 8080` | `4477` |
101
- | snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | `<home>/.claude/tarmac/snapshots` |
151
+ | snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | the path frozen into the installed wrapper — failing that `$XDG_STATE_HOME/tarmac/snapshots`, when it is absolute *and* the target home is your own, else `<home>/.local/state/tarmac/snapshots` |
152
+
153
+ **Flag beats environment beats config file beats default**, settled per setting; `serve`
154
+ opens by printing each effective value and where it came from. Nothing is silently dropped
155
+ or silently corrected — a value that will not parse stops the run and says what it got,
156
+ where it came from, and what would have worked, *including* values that were going to lose
157
+ the precedence fight anyway.
158
+
159
+ That last default is read out of the installed wrapper, never recomputed: a reader that
160
+ recomputed it would disagree with the writer the moment the two saw different environments
161
+ — `XDG_STATE_HOME` exported in your shell, absent from a LaunchAgent or cron — and the
162
+ symptom is a healthy, empty fleet, the one failure a fleet monitor may not have. Anything
163
+ else reading those payloads can ask where they are: `tarmac list --json` reports the path as
164
+ `health.snapshotsDir`. Full rules and edge cases: [`docs/MANUAL.md`](docs/MANUAL.md).
102
165
 
103
- **Flag beats environment beats config file beats default**, settled per setting. `serve`
104
- opens by printing each effective value and which source it came from. Nothing is ever
105
- silently dropped or silently corrected: a value that will not parse stops the run and says
106
- what it got, where it came from, and what would have worked — including values that were
107
- going to lose the precedence fight anyway. Full rules and edge cases: `docs/MANUAL.md`.
166
+ `tarmac list --json` also reports `health.unfilable`: how many live sessions carry an id
167
+ tarmac will never file a snapshot under, so a reader can tell telemetry that is *late* from
168
+ telemetry that is *not coming*. See [`docs/MANUAL.md`](docs/MANUAL.md) for what makes an id
169
+ filable.
108
170
 
109
- ## What V1 does not do
171
+ ## What it deliberately does not do
110
172
 
111
173
  - **No "waiting for you" signal.** The obvious missing column — which session is blocked on
112
174
  a human — is deliberately absent: the status line payload carries nothing that means it,
@@ -126,8 +188,8 @@ npm run build # flat JavaScript into dist/
126
188
  CI runs the suite on Node 22 and 24, on Linux and macOS, with `TARMAC_REQUIRE_DASH=1` so a
127
189
  machine without dash cannot report a green build it did not earn; a separate job builds
128
190
  `dist/` and runs it on Node 20 — the oldest version `engines` promises, and the only place
129
- the published artefact is ever executed. Releases are cut by hand (`PUBLISHING.md`);
130
- capturing fixtures for a new Claude Code build: `docs/MANUAL.md`.
191
+ the published artefact is ever executed. Releases are cut by hand ([`PUBLISHING.md`](PUBLISHING.md));
192
+ capturing fixtures for a new Claude Code build: [`docs/MANUAL.md`](docs/MANUAL.md).
131
193
 
132
194
  The suite runs the TypeScript sources directly through Node's type stripping, so it needs
133
195
  Node ≥ 22.18 to *develop*; what ships in `dist/` is plain ES2022 and runs on Node ≥ 20.
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from './args.js';
13
13
  import { collectFleet } from './collect.js';
14
14
  import { readConfigFile, resolveConfig } from './config.js';
15
15
  import { createFleetServer, listenFleetServer } from './server.js';
16
- import { install, uninstall, paths, planInstall, planUninstall } from './install.js';
16
+ import { install, uninstall, paths, planInstall, planUninstall, installedSnapshotsDir, wrapperIsOurs } from './install.js';
17
17
  import { confirmTyped } from './prompt.js';
18
18
  import { reapOrphanedTemps } from './reap.js';
19
19
  import { renderPlan, renderSettings, renderTable, restoreMeaning, servingLine } from './render.js';
@@ -39,7 +39,8 @@ const USAGE = `tarmac — fleet observability for Claude Code
39
39
  --port the dashboard's port (default: 4477 — a busy default walks up to the
40
40
  next free port, a port named here refuses instead)
41
41
  --snapshots-dir where the chained statusline drops its payloads
42
- (default: <home>/.claude/tarmac/snapshots)
42
+ (default: $XDG_STATE_HOME/tarmac/snapshots, or
43
+ <home>/.local/state/tarmac/snapshots)
43
44
  --claude-bin path to the claude CLI (default: claude)
44
45
 
45
46
  Those three settings can also be set, in decreasing order of precedence, by the
@@ -78,6 +79,11 @@ try {
78
79
  console.log(res.alreadyInstalled
79
80
  ? `install: already installed — wrapper regenerated, settings.json left alone`
80
81
  : `install: statusLine wrapped — undo with \`${plan.undo}\``);
82
+ // What the plan promised, as it actually went: a frame of the OLD wrapper can land in
83
+ // that directory between the two, so the count is reported rather than assumed.
84
+ if (res.legacy !== null)
85
+ console.log(`install: cleared ${res.legacy.payloads} runtime payload(s) from ${res.legacy.dir} — they belong in ${res.snapshots}` +
86
+ (res.legacy.kept > 0 ? ` (${res.legacy.kept} file(s) kept, so the directory stays)` : ''));
81
87
  }
82
88
  else {
83
89
  const { mode } = uninstall({ home });
@@ -88,11 +94,20 @@ try {
88
94
  // Only the reading commands resolve settings, and only they read the config file: a
89
95
  // typo in it must not be what stands between a user and `tarmac uninstall`.
90
96
  const p = paths(args.home ?? os.homedir());
97
+ // The installed wrapper's own path, when there is one: the default is where the WRITER
98
+ // writes, not where a reader's environment would have put it. Recomputing it here made
99
+ // `XDG_STATE_HOME` in one process and not the other a silent split.
100
+ const frozen = installedSnapshotsDir(p);
101
+ if (frozen === null && wrapperIsOurs(p))
102
+ console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
91
103
  const config = resolveConfig({
92
104
  flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir },
93
105
  env: process.env,
94
106
  file: readConfigFile(p.config),
95
- defaultSnapshotsDir: p.snapshots,
107
+ // The installed wrapper's own path, when there is one: the default is where the
108
+ // WRITER writes, not where a reader's environment would have put it. Recomputing it
109
+ // here made `XDG_STATE_HOME` in one process and not the other a silent split.
110
+ defaultSnapshotsDir: frozen ?? p.snapshots,
96
111
  });
97
112
  const snapshotsDir = config.snapshotsDir.value;
98
113
  const staleAfterMs = config.staleAfterMs.value;
@@ -108,7 +123,7 @@ try {
108
123
  if (failed > 0)
109
124
  console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
110
125
  const server = createFleetServer({
111
- collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source }),
126
+ collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
112
127
  });
113
128
  // A port nobody chose is not worth failing over: this walks past a busy 4477 and says
114
129
  // where it landed. A port that WAS chosen refuses instead, and the refusal leaves
@@ -123,7 +138,7 @@ try {
123
138
  console.log(servingLine(bound));
124
139
  }
125
140
  else {
126
- const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source });
141
+ const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null });
127
142
  if (args.watch) {
128
143
  // One redraws a screen, the other is meant to be piped once. Silently letting one win
129
144
  // is how someone ends up parsing a frame of terminal art.
package/dist/collect.js CHANGED
@@ -3,22 +3,30 @@ import { SOURCE_PHRASE } from './config.js';
3
3
  import { discoverSessions } from './discover.js';
4
4
  import { readSnapshots } from './snapshots.js';
5
5
  import { buildFleet } from './fleet.js';
6
- export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', }) {
6
+ export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', installed = false, }) {
7
7
  const { sessions, health: discovery } = await discoverSessions({ claudeBin });
8
8
  const { snapshots, dirError, unreadable, duplicates, dirMissing } = readSnapshots(snapshotsDir, { now });
9
9
  const fleet = buildFleet({ sessions, snapshots, now, discovery, staleAfterMs });
10
10
  // Both blind spots travel with the data: a directory we could not read and files we
11
11
  // could not parse are OUR failures to report, not silence to render as "all clear".
12
12
  //
13
- // An absent directory is judged HERE, because this is the layer that knows who chose it:
14
- // the default may simply not exist yet (nothing has been chained), but a path someone
15
- // typed flag, environment, or a config file edited months ago that is not there is a
16
- // setting pointing at nothing. Rendered as "not chained yet" it sends the user to run
17
- // `tarmac install`, which cannot fix it: install writes where install writes.
18
- fleet.health.snapshotsError =
19
- dirMissing && snapshotsDirSource !== 'default'
13
+ // An absent directory is judged HERE, because this is the layer that knows who chose it.
14
+ // A path someone typed flag, environment, or a config file edited months ago — that is
15
+ // not there is a setting pointing at nothing; rendered as "not chained yet" it sends the
16
+ // user to run `tarmac install`, which cannot fix it: install writes where install writes.
17
+ //
18
+ // Exactly ONE case is innocent, and it is narrower than it used to be: no install here at
19
+ // all. Since #20 the default is the path the install FROZE into the wrapper — a directory
20
+ // that was chosen and created by a run that happened — so its absence means the writer and
21
+ // the reader have parted company, which renders as a healthy, empty fleet. That is the one
22
+ // failure this tool may not have.
23
+ fleet.health.snapshotsError = !dirMissing
24
+ ? dirError
25
+ : snapshotsDirSource !== 'default'
20
26
  ? `ENOENT: ${snapshotsDir} does not exist — set by ${SOURCE_PHRASE[snapshotsDirSource]}`
21
- : dirError;
27
+ : installed
28
+ ? `ENOENT: ${snapshotsDir} does not exist — the installed wrapper writes there`
29
+ : dirError;
22
30
  fleet.health.snapshotsUnreadable = unreadable;
23
31
  fleet.health.snapshotsDuplicates = duplicates;
24
32
  fleet.health.snapshotsDir = snapshotsDir;
package/dist/fleet.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import path from 'node:path';
14
14
  import { DEFAULT_STALE_AFTER_MS } from './config.js';
15
15
  import { guardVersions } from './schema.js';
16
+ import { SID_NAME } from './wrapper.js';
16
17
  export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_STALE_AFTER_MS, discovery = null, }) {
17
18
  const rows = sessions.map((s) => {
18
19
  const t = (s.sessionId && snapshots.get(s.sessionId)) || null;
@@ -22,6 +23,7 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
22
23
  project: s.cwd ? path.basename(s.cwd) : null,
23
24
  cwd: s.cwd,
24
25
  pid: s.pid,
26
+ kind: s.kind,
25
27
  status: s.status,
26
28
  busy: s.busy,
27
29
  uptimeMs: typeof s.startedAt === 'number' ? now - s.startedAt : null,
@@ -39,6 +41,14 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
39
41
  });
40
42
  rows.sort((a, b) => rank(a) - rank(b) || (b.ctxPct ?? -1) - (a.ctxPct ?? -1));
41
43
  const covered = rows.filter((r) => r.ctxState !== 'absent').length;
44
+ // Blind AND unfilable, in that order — this number exists to say how many of the blind will
45
+ // stay blind, and both renderers subtract it from them. A session can be unfilable and
46
+ // covered at the same time: a snapshot written by a pre-upgrade wrapper under a non-UUID
47
+ // name is still READ, because the reader keys on the `session_id` inside the file rather
48
+ // than on the filename. Counting that one would push this past the blind count and make the
49
+ // renderers explain away someone else's missing telemetry.
50
+ // A null id is `noSessionId`'s business — a discovery failure, not a naming one.
51
+ const unfilable = rows.filter((r) => r.ctxState === 'absent' && r.sessionId !== null && !SID_NAME.test(r.sessionId)).length;
42
52
  const drift = rows.filter((r) => r.ctxState === 'drift').length;
43
53
  // Having a snapshot and having a cost are different facts, and only the second one is
44
54
  // allowed to feed the total.
@@ -55,6 +65,7 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
55
65
  health: {
56
66
  sessions: rows.length,
57
67
  covered,
68
+ unfilable,
58
69
  drift,
59
70
  stale: rows.filter((r) => r.stale).length,
60
71
  // Discovery's own blind spots. Dropping them turns a renamed `sessionId` into the