@adrrr/tarmac 0.2.0 → 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,15 +1,17 @@
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
17
  apollo busy 28% 7m Fable 5 max $41.20 8h
@@ -17,51 +19,93 @@ mercury-dashboard busy 27% 22m ! Fable 5 max $62.75 8h
17
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
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):
49
+
50
+ ```
51
+ tarmac install — your 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
36
59
 
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.
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.
42
67
 
43
- The only things it puts under `~/.claude/` are the wrapper and the `backup.json` that undoes
44
- it, neither of which changes at runtime the snapshots the wrapper writes at every frame go
45
- to `$XDG_STATE_HOME/tarmac/snapshots` (`~/.local/state/tarmac/snapshots` by default), because
46
- `~/.claude` is a directory people commit.
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.
47
74
 
48
75
  ## The dashboard
49
76
 
50
77
  `tarmac serve` puts the same fleet in the browser — every session a row, ages that keep
51
- 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:
52
80
 
53
81
  ```
54
82
  tarmac serving http://127.0.0.1:4477
55
83
  ```
56
84
 
57
- It binds to loopback and refuses any request whose `Host` is not loopback your cwd
58
- 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).
59
103
 
60
104
  ## Why it does not break
61
105
 
62
- Every other way to watch a Claude Code fleet reads something Claude Code never promised
63
- would stay put: transcript files, terminal panes, undocumented paths. Those tools break on
64
- 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.
65
109
 
66
110
  tarmac reads two things instead:
67
111
 
@@ -71,47 +115,60 @@ tarmac reads two things instead:
71
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** |
72
116
 
73
117
  That second line is the honest caveat, and it is the reason the real defence is not
74
- immunity, it is **visible degradation**: a null renders as `— no turn yet`, a moved field
75
- as `— schema drift`, a stale reading as the value **dated**never a confident `0`. And
76
- when a session shows up on a Claude Code build no fixture covers, tarmac names that
77
- version *before* anything breaks, and keeps reporting. The full state-by-state table is
78
- 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).
79
126
 
80
127
  ## Commands
81
128
 
82
129
  | Command | What it does | Options |
83
130
  |---|---|---|
84
- | `tarmac list` | one-shot fleet table | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
85
- | `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` |
86
133
  | `tarmac install` | chain the status line under `<home>/.claude/settings.json`, after confirmation | `--home`, `--yes` |
87
134
  | `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
88
135
 
89
136
  `--help` works everywhere. An option handed to a command that does not read it is an
90
- **error**, not something quietly ignored.
91
-
92
- Both live views tell you **when the last good reading arrived** and **whether the last
93
- refresh failed** ages keep climbing whether or not the refresh works, failures are
94
- banners with names, and a table is never thrown away for one. How each failure mode is
95
- 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.
96
141
 
97
142
  ## Configuration
98
143
 
99
144
  Three numbers are opinions, not truths, so all three are yours; everything else is
100
145
  deliberately not configurable, and all of it works with no configuration at all.
101
146
 
102
- | Setting | Flag | Environment | `~/.claude/tarmac/config.json` | Default |
147
+ | Setting | Flag | Environment | `<home>/.claude/tarmac/config.json` | Default |
103
148
  |---|---|---|---|---|
104
149
  | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` | `"staleAfterMs": 90000` | `10m` |
105
150
  | port | `--port 8080` | `TARMAC_PORT` | `"port": 8080` | `4477` |
106
- | snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | `$XDG_STATE_HOME/tarmac/snapshots`, else `<home>/.local/state/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).
107
165
 
108
- **Flag beats environment beats config file beats default**, settled per setting. `serve`
109
- opens by printing each effective value and which source it came from. Nothing is ever
110
- silently dropped or silently corrected: a value that will not parse stops the run and says
111
- what it got, where it came from, and what would have worked — including values that were
112
- 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.
113
170
 
114
- ## What V1 does not do
171
+ ## What it deliberately does not do
115
172
 
116
173
  - **No "waiting for you" signal.** The obvious missing column — which session is blocked on
117
174
  a human — is deliberately absent: the status line payload carries nothing that means it,
@@ -131,8 +188,8 @@ npm run build # flat JavaScript into dist/
131
188
  CI runs the suite on Node 22 and 24, on Linux and macOS, with `TARMAC_REQUIRE_DASH=1` so a
132
189
  machine without dash cannot report a green build it did not earn; a separate job builds
133
190
  `dist/` and runs it on Node 20 — the oldest version `engines` promises, and the only place
134
- the published artefact is ever executed. Releases are cut by hand (`PUBLISHING.md`);
135
- 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).
136
193
 
137
194
  The suite runs the TypeScript sources directly through Node's type stripping, so it needs
138
195
  Node ≥ 22.18 to *develop*; what ships in `dist/` is plain ES2022 and runs on Node ≥ 20.
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
package/dist/install.js CHANGED
@@ -238,22 +238,38 @@ function readLegacyDir(p) {
238
238
  /**
239
239
  * The three names the wrapper writes into its snapshot directory, and nothing else.
240
240
  *
241
- * `SNAPSHOT_NAME` is the PRUNER's shape (8-4-4-4-12), deliberately narrower than the ids the
242
- * writer accepts (`[0-9a-zA-Z-]{8,64}` it refuses to guess what an id may look like). One
243
- * choice, made once, and it is what produces the residue this branch states: a session id
244
- * wider than a UUID is written here and swept by neither the pruner nor this. Widening it
245
- * would mean deleting, from inside a git repository, by a shape rather than by a signature.
241
+ * `SNAPSHOT_NAME` is the writer's own rule now, not a second reading of it (#7): this purge
242
+ * deletes from inside `~/.claude`, a directory people version-control, and a `<sid>.json` is
243
+ * recognised by SHAPE alone so the shape had better be one the wrapper can actually
244
+ * produce, or this deletes by resemblance in the worst place to be wrong about it.
245
+ *
246
+ * `TEMP_PREFIX` is deliberately the whole test for a temp file, and deliberately looser than
247
+ * `reap.ts`'s `<prefix><sid>.<pid>.tmp`. The two answer different questions. `reap` runs on
248
+ * every `serve` tick, in a directory a reader may have been pointed at and another program
249
+ * may own, so it takes only the exact name this wrapper emits. This runs once, at install,
250
+ * behind `tarmacWasInstalledHere` — provenance already proven — over a directory whose whole
251
+ * purpose was to be ours, and its job is to leave nothing behind so the directory itself can
252
+ * go. There, a name only tarmac ever writes is signature enough.
246
253
  */
247
254
  const isPayloadName = (name) => SNAPSHOT_NAME.test(name) || name.startsWith(TEMP_PREFIX) || name === PRUNE_MARKER;
248
- /** `lstat`: the LINK's own kind decides, since `unlink` would remove the link, not its target. */
249
- const isPlainFile = (file) => {
255
+ /**
256
+ * What is at that path, with the two "no"s kept apart so a plan can say which one it is:
257
+ * nothing there at all, or something there that is not ours to take.
258
+ *
259
+ * `lstat`: the LINK's own kind decides, since `unlink` would remove the link, not its target.
260
+ * A judgement by `existsSync` would follow a dead symlink to "nothing there" while the removal
261
+ * left the link exactly where it was.
262
+ */
263
+ const markerState = (file) => {
250
264
  try {
251
- return fs.lstatSync(file).isFile();
265
+ return fs.lstatSync(file).isFile() ? 'file' : 'not-a-file';
252
266
  }
253
267
  catch {
254
- return false;
268
+ return 'none';
255
269
  }
256
270
  };
271
+ /** The same question, for callers that only need the yes. */
272
+ const isPlainFile = (file) => markerState(file) === 'file';
257
273
  /**
258
274
  * Did an install of OURS already exist here, before this run wrote anything?
259
275
  *
@@ -721,6 +737,7 @@ export function planUninstall({ home, realHome = os.homedir() }) {
721
737
  after = restored ? commandOf(settings.statusLine) : commandOf(current.statusLine);
722
738
  }
723
739
  const isRealHome = sameFile(root, realHome);
740
+ const snapshots = installedSnapshotsDir(p);
724
741
  return {
725
742
  action: 'uninstall',
726
743
  home: root,
@@ -732,8 +749,10 @@ export function planUninstall({ home, realHome = os.homedir() }) {
732
749
  after,
733
750
  mode,
734
751
  // Where they REALLY are: `uninstall` leaves them behind, so the path it prints has to be
735
- // the wrapper's own, not one recomputed from this shell's environment.
736
- snapshots: installedSnapshotsDir(p) ?? p.snapshots,
752
+ // the wrapper's own, not one recomputed from this shell's environment — and when the
753
+ // wrapper cannot answer, neither can the plan. The same `null` `uninstall` acts on.
754
+ snapshots,
755
+ marker: snapshots === null ? null : markerState(path.join(snapshots, PRUNE_MARKER)),
737
756
  undo: undoCommand('install', root, isRealHome),
738
757
  };
739
758
  }
@@ -743,10 +762,20 @@ function installedBackupOrRefuse(p) {
743
762
  throw new Error(`no tarmac install found under ${p.dir}`);
744
763
  return backup;
745
764
  }
765
+ function removePruneMarker(snapshots) {
766
+ if (snapshots === null)
767
+ return;
768
+ const marker = path.join(snapshots, PRUNE_MARKER);
769
+ if (isPlainFile(marker))
770
+ fs.rmSync(marker, { force: true });
771
+ }
746
772
  export function uninstall({ home }) {
747
773
  const root = requireHome(home);
748
774
  const p = paths(root);
749
775
  const backup = installedBackupOrRefuse(p);
776
+ // Read this before removing the wrapper: it is the source of truth when the install used
777
+ // XDG_STATE_HOME, and the marker is the only file in that directory uninstall owns.
778
+ const snapshots = installedSnapshotsDir(p);
750
779
  const currentText = fs.existsSync(p.settings) ? fs.readFileSync(p.settings, 'utf8') : null;
751
780
  let mode;
752
781
  if (currentText === backup.installedText) {
@@ -774,7 +803,10 @@ export function uninstall({ home }) {
774
803
  writeAtomic(p.settings, JSON.stringify(settings, null, 2) + '\n');
775
804
  mode = 'surgical';
776
805
  }
777
- // Snapshots are data the user may still want; only what we generated goes.
806
+ // Restore settings before touching runtime state: even an unreadable snapshots directory
807
+ // must not strand statusLine on the wrapper we are uninstalling.
808
+ removePruneMarker(snapshots);
809
+ // Snapshot payloads are data the user may still want; only what we generated goes.
778
810
  fs.rmSync(p.wrapper, { force: true });
779
811
  fs.rmSync(p.backup, { force: true });
780
812
  return { mode };
package/dist/map.js ADDED
@@ -0,0 +1,110 @@
1
+ // P4 — the map's model.
2
+ //
3
+ // A view over the fleet `buildFleet` already produced. It opens no second source: every
4
+ // field below is derived from a row that is already on the page as a table line.
5
+ /**
6
+ * How recently a snapshot must have landed for its node to pulse. Two of the page's poll
7
+ * intervals (5s): a session whose terminal keeps drawing frames keeps its heartbeat across
8
+ * consecutive renders, and one that has stopped goes quiet within two of them.
9
+ *
10
+ * It is a display window, not a health threshold — `--stale-after` is the one that judges,
11
+ * and it wins wherever the two disagree.
12
+ */
13
+ export const PULSE_WITHIN_MS = 10_000;
14
+ /**
15
+ * Where an agent is placed, and why it is a placement rather than a link.
16
+ *
17
+ * `claude agents --json` prints interactive and background sessions in one array, and
18
+ * publishes nothing that ties an agent to whoever dispatched it. The working directory is
19
+ * the only field both carry, so it is what an agent is placed BY — it lands next to the
20
+ * session sharing its directory, and nothing is ever nested inside anything. Nesting would
21
+ * assert a parentage the source does not contain, and it would let this page show a smaller
22
+ * fleet than the table beside it.
23
+ *
24
+ * The agents are gathered separately because the fleet sorts busy sessions first, so one can
25
+ * arrive before the session it belongs beside. An agent whose directory matches no session
26
+ * keeps a node of its own, at the end.
27
+ */
28
+ export function buildMap({ rows }, { pulseWithinMs = PULSE_WITHIN_MS } = {}) {
29
+ // Whether this fleet still speaks the kind we know. If NOTHING calls itself `interactive`,
30
+ // the word moved rather than every terminal on the machine going background at once — and
31
+ // the map says so by drawing them all as what they almost certainly still are. It is the
32
+ // tolerance `buildFleet` already applies to telemetry: a signal true of every row is a
33
+ // change in the source.
34
+ const anchored = rows.some((r) => r.kind === INTERACTIVE);
35
+ const roleOf = (r) => !anchored || r.kind === null || r.kind === INTERACTIVE ? 'session' : 'agent';
36
+ const node = (row) => {
37
+ const reading = readingOf(row);
38
+ return {
39
+ row,
40
+ role: roleOf(row),
41
+ state: stateOf(row),
42
+ reading,
43
+ measured: row.ctxPct !== null,
44
+ // Three conditions, and each one is a way the halo could otherwise lie. `live` first,
45
+ // and not merely "young": a reading the fleet calls stale may not be animated as
46
+ // though it were breathing, and with `--stale-after 2s` a three-second-old reading is
47
+ // both stale and inside the window below. `measured` last: a file landing is not a
48
+ // reading landing, and a drifted fleet still writes a snapshot every frame.
49
+ pulse: reading === 'live' &&
50
+ row.ctxPct !== null &&
51
+ row.snapshotAgeMs !== null &&
52
+ row.snapshotAgeMs <= pulseWithinMs,
53
+ };
54
+ };
55
+ const agents = rows.filter((r) => roleOf(r) === 'agent');
56
+ const placed = new Set();
57
+ const seen = new Set();
58
+ const nodes = [];
59
+ for (const r of rows) {
60
+ if (roleOf(r) !== 'session')
61
+ continue;
62
+ nodes.push(node(r));
63
+ // Only the first session of a directory collects them, or two sessions in one checkout
64
+ // would each grow a copy of the same agents.
65
+ if (r.cwd === null || seen.has(r.cwd))
66
+ continue;
67
+ seen.add(r.cwd);
68
+ for (const a of agents) {
69
+ // Two directories nobody could read are not the same directory.
70
+ if (a.cwd === null || a.cwd !== r.cwd)
71
+ continue;
72
+ nodes.push(node(a));
73
+ placed.add(a);
74
+ }
75
+ }
76
+ for (const a of agents)
77
+ if (!placed.has(a))
78
+ nodes.push(node(a));
79
+ return { nodes };
80
+ }
81
+ /**
82
+ * The kind a terminal calls itself, and the anchor this module reasons from. A background
83
+ * entry has since been seen beside them — `kind: 'background'`, no `pid`, its word under
84
+ * `state` rather than `status` — so the two are no longer a reading of that CLI's help. It is
85
+ * still the anchor and never the list: one observed alternative is not the vocabulary, and the
86
+ * heuristic above asks only whether anything on this machine still calls itself `interactive`.
87
+ *
88
+ * An ABSENT kind is not evidence of an agent either: the same rule the session status follows
89
+ * one module down, where unrecognised means unknown, never "the quiet one". The two mistakes
90
+ * are not the same size — an agent drawn as a session is a node in the wrong shape, while a
91
+ * session drawn as an agent is a terminal someone is working in, reduced to a footnote of a
92
+ * directory it merely shares.
93
+ */
94
+ export const INTERACTIVE = 'interactive';
95
+ /**
96
+ * `stale` is not recomputed here — it is the collector's verdict, reached against the
97
+ * threshold this run resolved (`--stale-after`, the environment, the config file). A second
98
+ * opinion in this module would let the map and the table disagree about the same session on
99
+ * the same page.
100
+ */
101
+ function readingOf(r) {
102
+ if (r.snapshotAgeMs === null)
103
+ return 'none';
104
+ // A snapshot dated after the clock reading it: an NTP correction, a mount whose time runs
105
+ // ahead. Its age is not a small number, it is not a number at all.
106
+ if (r.snapshotAgeMs < 0)
107
+ return 'undated';
108
+ return r.stale ? 'stale' : 'live';
109
+ }
110
+ export const stateOf = (r) => (r.busy === true ? 'busy' : r.busy === false ? 'idle' : 'unknown');
package/dist/reap.js CHANGED
@@ -16,12 +16,17 @@
16
16
  // in flight, and deleting it would be the reaper causing the corruption it prevents.
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
- import { TEMP_PREFIX } from './wrapper.js';
19
+ import { SID_GLOB, TEMP_PREFIX } from './wrapper.js';
20
20
  /** Exported so a test can build the same expectation from the same constant, escaped. */
21
21
  export const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
- // `<TEMP_PREFIX><sid>.<pid>.tmp` — the sid charset and length are the ones the wrapper
23
- // enforces before it agrees to use the value as a filename, and the pid is what `$$` emits.
24
- const TEMP_NAME = new RegExp(`^${escapeRe(TEMP_PREFIX)}[0-9A-Za-z-]{8,64}\\.\\d+\\.tmp$`);
22
+ // `<TEMP_PREFIX><sid>.<pid>.tmp` — the pid is what `$$` emits, and the sid is the wrapper's
23
+ // own rule, read from the constant rather than transcribed: a set of its own is how this
24
+ // matcher came to accept names the writer had stopped producing (#7).
25
+ //
26
+ // `SID_GLOB` goes in RAW, unlike the prefix: it is a shell pattern made of bracket
27
+ // expressions and literal `-`, which is already valid regex meaning the same set. Escaping
28
+ // it would turn the classes into literal brackets and match nothing at all.
29
+ const TEMP_NAME = new RegExp(`^${escapeRe(TEMP_PREFIX)}${SID_GLOB}\\.\\d+\\.tmp$`);
25
30
  /** An hour is orders of magnitude beyond any real frame, and cheap to be wrong about. */
26
31
  const DEFAULT_OLDER_THAN_MS = 60 * 60_000;
27
32
  /**
package/dist/render.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // stays invisible for days. The two surfaces say the same things in their own words — so
8
8
  // they are written side by side, and the suite can reach both.
9
9
  import { formatDuration } from './config.js';
10
+ import { buildMap, INTERACTIVE, stateOf } from './map.js';
10
11
  import { schemaNotice } from './schema.js';
11
12
  /**
12
13
  * The other thing this module renders: the plan a user consents to before install or
@@ -52,9 +53,16 @@ export function renderPlan(plan) {
52
53
  }
53
54
  else {
54
55
  rows.push(['restore', `${plan.mode} — ${restoreMeaning(plan.mode)}`]);
55
- // "Your snapshots survive uninstall" is a promise about a directory nobody can guess any
56
- // more, so the command that leaves them behind says where it leaves them.
57
- rows.push(['snapshots', `${plan.snapshots} (left exactly as they are)`]);
56
+ // Nothing here says where the payloads are no wrapper, or one that no longer carries
57
+ // the path, or one we cannot read. There is no directory to name, and `uninstall` opens
58
+ // none and removes nothing in one. Printing the default we would have computed, beside a
59
+ // promise to clear a marker out of it, is a plan disagreeing with what runs.
60
+ if (plan.snapshots === null) {
61
+ rows.push(['snapshots', 'unknown — nothing here says where; nothing there is opened or removed']);
62
+ }
63
+ else {
64
+ rows.push(['snapshots', `${plan.snapshots} (snapshot files stay; ${markerFate(plan.mode, plan.marker)})`]);
65
+ }
58
66
  }
59
67
  rows.push(['undo', plan.undo]);
60
68
  const w = Math.max(...rows.map(([label]) => label.length));
@@ -75,6 +83,26 @@ const gitHint = (repo, hasLegacy) => `${repo.dir} is a git repository — ` +
75
83
  (hasLegacy
76
84
  ? `commit the removal above, and add \`${repo.ignore}\` to its .gitignore`
77
85
  : 'nothing tarmac writes there changes at runtime; the snapshots live outside it');
86
+ /**
87
+ * What becomes of the prune marker, said only after looking at it.
88
+ *
89
+ * Three of these four answers are "it stays", each for its own reason: a foreign statusLine
90
+ * keeps the wrapper, so the marker keeps its owner; nothing is there to take; or what is there
91
+ * is not a plain file, which `removePruneMarker` refuses by design because `unlink` would take
92
+ * a link and not its target.
93
+ *
94
+ * Written so that ONLY `'file'` can reach the removal sentence, rather than letting it be the
95
+ * fallthrough: `marker` is nullable by type, and a null landing on "is removed" would be the
96
+ * exact promise this whole change exists to stop making. The safe answer is the default; the
97
+ * dangerous one is the special case.
98
+ */
99
+ const markerFate = (mode, marker) => marker !== 'file'
100
+ ? marker === 'not-a-file'
101
+ ? "the prune marker's name is worn by something that is not a regular file, so it stays"
102
+ : 'no prune marker to remove'
103
+ : mode === 'foreign'
104
+ ? "tarmac's prune marker stays"
105
+ : "tarmac's prune marker is removed";
78
106
  /** What each restore mode means, in the words the plan and the report both use. */
79
107
  export const restoreMeaning = (mode) => RESTORE_MEANING[mode];
80
108
  const RESTORE_MEANING = {
@@ -148,7 +176,13 @@ export function renderTable({ rows, health }) {
148
176
  else if (health.schemaBroken)
149
177
  warns.push('! every snapshot drifted — the statusline payload schema moved');
150
178
  else if (health.covered < health.sessions)
151
- warns.push(`! statusline chained on ${health.covered}/${health.sessions} sessions`);
179
+ warns.push(
180
+ // The count travels, for the same reason `unreadable` does one line up: without it
181
+ // this line reads as "run install", and for a session id the wrapper declines to file
182
+ // that is advice already taken which can never work.
183
+ health.unfilable > 0
184
+ ? `! statusline chained on ${health.covered}/${health.sessions} sessions — ${health.unfilable} session(s) with an id tarmac never files`
185
+ : `! statusline chained on ${health.covered}/${health.sessions} sessions`);
152
186
  if (health.stale > 0)
153
187
  warns.push(`! ${health.stale} reading(s) marked "!" are older than ${formatDuration(health.staleAfterMs)} (--stale-after)`);
154
188
  const skewed = rows.filter(ahead).length;
@@ -194,7 +228,8 @@ function age(ms) {
194
228
  * could not see) are hard-won and tested; re-deriving them in browser JavaScript to redraw a
195
229
  * polled row would put the second copy somewhere this suite cannot reach.
196
230
  */
197
- export function renderLive({ rows, health }) {
231
+ export function renderLive(fleet) {
232
+ const { rows, health } = fleet;
198
233
  const warnings = [];
199
234
  if (health.noSessionId > 0) {
200
235
  // Never "no sessions found" when discovery DID find some it could not identify.
@@ -216,7 +251,12 @@ export function renderLive({ rows, health }) {
216
251
  warnings.push(`Every snapshot drifted — Claude Code's statusline schema has probably moved. Context readings are dead until the payload shape is re-checked.`);
217
252
  }
218
253
  else if (health.covered < health.sessions) {
219
- warnings.push(`Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`);
254
+ const blind = health.sessions - health.covered;
255
+ warnings.push(health.unfilable === 0
256
+ ? `Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`
257
+ : health.unfilable >= blind
258
+ ? `Statusline chained on ${health.covered}/${health.sessions} sessions — the rest carry a session id that is not the UUID tarmac files snapshots under, so no frame will ever produce one. Installing again will not change that.`
259
+ : `Statusline chained on ${health.covered}/${health.sessions} sessions — ${blind} report no context, and ${health.unfilable} of them will never be filed: the session id is not the UUID tarmac files snapshots under. For the others, run \`tarmac install\` and give them one TUI frame.`);
220
260
  }
221
261
  if (health.unknownStatus > 0) {
222
262
  warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
@@ -231,22 +271,34 @@ export function renderLive({ rows, health }) {
231
271
  const schema = schemaNotice(health.schemaGuard);
232
272
  if (schema)
233
273
  warnings.push(schema);
274
+ // Both views, every time, out of the one reading the page just asked for. The tabs are
275
+ // links and the shell decides which of the two is visible, so a fleet cannot be drawn as a
276
+ // table of one age beside a map of another.
277
+ //
278
+ // A fleet with nothing in it has no two ways to be laid out, so it gets one sentence above
279
+ // both of them rather than a copy inside each — the copy behind `display:none` was invisible
280
+ // on screen and read out all the same by anything going through the markup.
234
281
  const body = rows.length === 0
235
- ? health.noSessionId > 0
236
- ? // Discovery DID return entries — we just could not identify them. Saying "none
237
- // found" here would hide a schema change behind a calm, wrong answer.
238
- `<p class="empty">No session could be identified, though ${health.noSessionId} were discovered.</p>`
239
- : `<p class="empty">No Claude Code sessions found. Is a session running?</p>`
240
- : `<table>
282
+ ? empty(health)
283
+ : `<div class="view view-table"><div class="wrap"><table>
241
284
  <thead><tr>
242
285
  <th>Project</th><th>Session</th><th>State</th><th>Context</th><th>Model</th><th>Effort</th><th>Cost</th><th>Uptime</th>
243
286
  </tr></thead>
244
287
  <tbody>${rows.map(renderRow).join('')}</tbody>
245
- </table>`;
288
+ </table></div></div>
289
+ <div class="view view-map">${renderMap(fleet)}</div>`;
246
290
  return `<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
247
291
  ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
248
- <div class="wrap">${body}</div>`;
292
+ ${body}`;
249
293
  }
294
+ /**
295
+ * What to say instead of a fleet. Discovery returning entries we could not identify is not
296
+ * an empty fleet, and saying "none found" would hide a schema change behind a calm, wrong
297
+ * answer — so the two surfaces below share one wording rather than each keeping its own.
298
+ */
299
+ const empty = (health) => health.noSessionId > 0
300
+ ? `<p class="empty">No session could be identified, though ${health.noSessionId} were discovered.</p>`
301
+ : `<p class="empty">No Claude Code sessions found. Is a session running?</p>`;
250
302
  /**
251
303
  * One frame of `tarmac list --watch`. It owes the reader exactly what the page owes: the
252
304
  * table, when the reading in it arrived, and whether the last attempt to refresh it failed.
@@ -293,7 +345,7 @@ function ago(ms) {
293
345
  const m = Math.round(s / 60);
294
346
  return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
295
347
  }
296
- export function renderPage(fleet) {
348
+ export function renderPage(fleet, view = 'table') {
297
349
  return `<!doctype html>
298
350
  <html lang="en"><head>
299
351
  <meta charset="utf-8">
@@ -344,6 +396,81 @@ export function renderPage(fleet) {
344
396
  body.failing .pulse { background:var(--warn); }
345
397
  body.failing #live { border:1px dashed var(--warn); border-radius:8px; padding:.5rem; }
346
398
  .offline strong { white-space:nowrap; }
399
+ /* The tabs, and what they hide. The shell owns the choice — not the fragment — so a poll
400
+ that swaps the fleet underneath cannot put the reader back on a view they left. */
401
+ nav { display:flex; gap:.15rem; }
402
+ nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
403
+ letter-spacing:.06em; padding:.15rem .55rem; border-radius:99px; border:1px solid transparent; }
404
+ nav a[aria-current="page"] { color:var(--fg); border-color:var(--line); }
405
+ body[data-view="table"] .view-map { display:none; }
406
+ body[data-view="map"] .view-table { display:none; }
407
+
408
+ /* ── the map ─────────────────────────────────────────────────────────────────────────
409
+ One node per session. The arc is the context, its weight is how much that reading may
410
+ be believed, and the halo — the only thing on this page that moves — says a frame
411
+ landed moments ago. */
412
+ .map { display:grid; gap:.9rem; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr)); }
413
+ .node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
414
+ display:flex; flex-direction:column; align-items:center; text-align:center; }
415
+ .node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
416
+ /* An agent is a smaller body in the same system, next to the session it shares a directory
417
+ with — never inside it. Tinted rather than outlined, and hooked, so it reads as the
418
+ session's dependent without a line claiming a parentage the source never published. */
419
+ .node[data-role="agent"] { padding-top:.55rem; border-color:transparent;
420
+ background:color-mix(in srgb, var(--line) 22%, transparent); }
421
+ .node[data-role="agent"] .dial { width:3.6rem; height:3.6rem; }
422
+ .node[data-role="agent"] .pct { font-size:.95rem; }
423
+ .node[data-role="agent"] .why { font-size:.6rem; max-width:3.4rem; }
424
+ .node[data-role="agent"] .why b { font-size:1rem; }
425
+ .node[data-role="agent"] .project { font-weight:400; }
426
+ /* Said, not shown: the three glyphs differ in silhouette, so a reader who cannot separate
427
+ two hues still has the state — but a screen reader is handed a bullet and nothing else. */
428
+ .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
429
+ .dial { position:relative; width:5.5rem; height:5.5rem; }
430
+ .dial svg { width:100%; height:100%; display:block; overflow:visible; }
431
+ .track { fill:none; stroke:var(--line); stroke-width:5; }
432
+ /* Butt caps, not round: a rounded cap adds half a stroke width at each end, which draws a
433
+ 1% reading at three times its extent. The prettier cap overstates every small number. */
434
+ .arc { fill:none; stroke:var(--dim); stroke-width:5; }
435
+ .node[data-state="busy"] .arc { stroke:var(--busy); }
436
+ /* A reading past the freshness threshold is drawn as what it is: thin, faded, and in the
437
+ warning hue — never the solid arc of a live one. Its EXTENT stays true, because the
438
+ number is still the truth of an earlier moment; and a dash pattern here would overwrite
439
+ stroke-dasharray, which is what carries the percentage. */
440
+ .node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
441
+ stroke:var(--warn); stroke-width:2.5; opacity:.7; }
442
+ /* Nothing was measured. Keyed on the measurement and never on the age of the file: a
443
+ solid empty ring is what a session measured at 0% wears, and the two must not match. */
444
+ .track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
445
+ /* Once per arrival, not forever: the fragment is replaced on every poll, so a single run
446
+ per swap is what makes the fleet breathe at the rate its frames actually land. A looping
447
+ animation would say "a frame just arrived" for five seconds after it stopped being true. */
448
+ .halo { fill:none; stroke:var(--busy); stroke-width:2; opacity:0; transform-origin:50% 50%;
449
+ animation:halo 1.6s ease-out 1; }
450
+ .node[data-state="idle"] .halo { stroke:var(--dim); }
451
+ @keyframes halo { from { opacity:.5; transform:scale(1); } to { opacity:0; transform:scale(1.22); } }
452
+ /* Motion is the one thing here nobody can look away from, so it is the first thing a
453
+ reader who asked for less of it stops getting. The reading is still readable without it. */
454
+ /* Scaled, not merely stopped: at rest the halo sits inside the track's own stroke, so
455
+ "animation:none" alone left the one fact it carries invisible. */
456
+ @media (prefers-reduced-motion: reduce) { .halo { animation:none; opacity:.35; transform:scale(1.18); } }
457
+ .val { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
458
+ font-variant-numeric:tabular-nums; }
459
+ .pct { font-size:1.25rem; font-weight:650; letter-spacing:-.01em; }
460
+ .pct i { font-style:normal; font-size:.7em; font-weight:500; color:var(--dim); }
461
+ .why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
462
+ .why b { display:block; font-size:1.25rem; font-weight:400; }
463
+ .who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
464
+ .who .project { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
465
+ .node[data-state="busy"] .who .project { font-weight:700; }
466
+ .shape { font-size:.7rem; color:var(--dim); }
467
+ .node[data-state="busy"] .shape { color:var(--busy); }
468
+ .node[data-state="unknown"] .shape { color:var(--warn); }
469
+ .sub { color:var(--dim); font-size:.76rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
470
+ .asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
471
+ .asof.stale { color:var(--warn); font-weight:600; }
472
+ @media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
473
+
347
474
  /* Below this the table stops being a table: one card per session, every value keeping the
348
475
  name of the column it came from. Nothing is dropped — a phone that hides the context
349
476
  column would be a phone that renders "not measured" as nothing at all. */
@@ -365,9 +492,17 @@ export function renderPage(fleet) {
365
492
  .bar { display:none; }
366
493
  }
367
494
  </style>
368
- </head><body>
495
+ </head><body data-view="${view}">
369
496
  <header>
370
497
  <h1>tarmac</h1>
498
+ <!-- Links, not buttons: the view survives a reload, a bookmark and a browser with
499
+ JavaScript off — the state of a page whose own noscript banner promises it is still
500
+ readable. Both views are in the fragment below either way, so switching costs the
501
+ server nothing and the two can never show readings of different ages. -->
502
+ <nav>
503
+ <a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
504
+ <a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
505
+ </nav>
371
506
  <!-- Not "updated just now". If the script never runs — a policy-injected CSP without
372
507
  'unsafe-inline', a script error — that text would stand as a permanent lie, and
373
508
  <noscript> would not fire to correct it because JavaScript is enabled. The page's one
@@ -506,7 +641,7 @@ const SCRIPT = `
506
641
  */
507
642
  function renderRow(r) {
508
643
  const state = stateOf(r);
509
- const word = r.busy === true ? 'busy' : r.busy === false ? 'idle' : (r.status ?? 'unknown');
644
+ const word = stateWord(state, r);
510
645
  // `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
511
646
  // and a value whose column has no name is a bare "—" that could mean anything.
512
647
  // Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
@@ -523,12 +658,106 @@ function renderRow(r) {
523
658
  <td data-label="Uptime" class="num dim"><span class="v">${r.uptimeMs === null ? dash() : esc(duration(r.uptimeMs))}</span></td>
524
659
  </tr>`;
525
660
  }
526
- const stateOf = (r) => (r.busy === true ? 'busy' : r.busy === false ? 'idle' : 'unknown');
527
661
  const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
662
+ /**
663
+ * The state in words, for both surfaces — derived from the state the MODEL decided, never
664
+ * from the row a second time. Two expressions for one fact on one element is how a node ends
665
+ * up shaped `unknown` and captioned `idle`.
666
+ *
667
+ * An unrecognised status is quoted as it came rather than flattened to "unknown": the point
668
+ * of keeping it is that someone reading the page can go and find out what `compacting` means.
669
+ */
670
+ const stateWord = (state, r) => state === 'unknown' ? (r.status ?? 'unknown') : state;
671
+ /**
672
+ * Which kind of missing a missing percentage is. One lookup for both surfaces: the table
673
+ * says it beside a dash, the map says it inside an empty dial, and a second copy of these
674
+ * three words is a second chance to describe the same state differently.
675
+ */
676
+ const CTX_WHY = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' };
677
+ /**
678
+ * The map: one node per session, laid out as a grid rather than a graph. An empty fleet is not
679
+ * its business — `renderLive` says that once, above both views, rather than letting each of
680
+ * them render the same sentence and hide one of the two.
681
+ *
682
+ * There are no edges because the sources publish no relationship between two sessions — the
683
+ * one thing they do carry is the working directory, and that is expressed by putting an
684
+ * agent NEXT to the session it shares a directory with, never by drawing a line that would
685
+ * claim more than the data says.
686
+ *
687
+ * Everything a reader interprets is decided in `map.ts` and rendered here, on the server,
688
+ * for the same reason the table is: the rules that keep a reading honest are tested, and a
689
+ * copy of them re-derived in browser JavaScript would sit where this suite cannot reach.
690
+ */
691
+ export function renderMap(fleet) {
692
+ return `<div class="map">${buildMap(fleet).nodes.map(renderNode).join('')}</div>`;
693
+ }
694
+ /**
695
+ * One node. Five facts, in five channels that do not depend on colour alone: the arc is how
696
+ * full the context is, the dial's weight is how much that reading may be believed, a dotted
697
+ * dial is no reading at all, the shape beside the name is the session's state, and the halo
698
+ * says one landed moments ago. The words under them are the same ones the table uses for the
699
+ * same conditions — including the halo's, which would otherwise live only in a drawing.
700
+ */
701
+ function renderNode({ row: r, role, state, reading, measured, pulse }) {
702
+ // The model owns "is there a number"; this reads its verdict rather than asking the row a
703
+ // second question. `fresh` and `drift` are the two states where the age of the file and the
704
+ // presence of a reading disagree, and they are the two that matter most.
705
+ const pct = measured ? r.ctxPct : null;
706
+ const value = pct === null
707
+ ? `<span class="why"><b>&mdash;</b>${esc(CTX_WHY[r.ctxState] ?? 'no reading')}</span>`
708
+ : `<span class="pct">${pct}<i>%</i></span>`;
709
+ // The reading's own age, and only when it is one the reader must not take for current.
710
+ const asOf = reading === 'stale' && r.snapshotAgeMs !== null
711
+ ? `<div class="asof stale">! ${esc(asOfAge(r.snapshotAgeMs))} ago</div>`
712
+ : reading === 'undated'
713
+ ? `<div class="asof stale">! undated</div>`
714
+ : '';
715
+ // An agent carries its own project, like every other node. Placement is not a promise —
716
+ // the grid wraps where the viewport says, and the fleet's sort can hand the same agent a
717
+ // different neighbour on the next poll — so nothing here points at the node beside it.
718
+ return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
719
+ <div class="dial">
720
+ <svg viewBox="0 0 80 80" aria-hidden="true">${pulse ? `<circle class="halo" cx="40" cy="40" r="${DIAL_R}"/>` : ''}<circle class="track${measured ? '' : ' unmeasured'}" cx="40" cy="40" r="${DIAL_R}"/>${pct === null ? '' : arc(pct)}</svg>
721
+ <div class="val">${value}</div>
722
+ ${pulse ? `<span class="sr">a reading just landed</span>` : ''}
723
+ </div>
724
+ <div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="project">${esc(r.project)}</span></div>
725
+ <div class="sub">${esc(r.name)}</div>
726
+ ${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
727
+ <div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
728
+ ${asOf}
729
+ </article>`;
730
+ }
731
+ /**
732
+ * How old a dated reading is, in the words both surfaces use.
733
+ *
734
+ * `duration` floors to whole minutes, and `--stale-after` takes seconds — so a 30s reading
735
+ * judged against a 2s threshold rendered "! 0m ago": the "!" saying past the threshold and
736
+ * the "0m" saying brand new, in the same breath. Under a minute the age stops pretending to
737
+ * be a round number.
738
+ */
739
+ const asOfAge = (ms) => (ms < 60_000 ? '<1m' : duration(ms));
740
+ /** The dial's geometry. One radius, named once, so the arithmetic below cannot drift from it. */
741
+ const DIAL_R = 30;
742
+ const DIAL_C = 2 * Math.PI * DIAL_R;
743
+ /**
744
+ * The filled part of the ring, as a fraction of the circle's real circumference.
745
+ *
746
+ * `pathLength="100"` would say the same thing in far prettier markup — "62 filled, 38 empty"
747
+ * — but it is an attribute browsers have not always honoured on basic shapes, and the way it
748
+ * fails is the one this page cannot afford: the dash array is ignored, the arc closes, and
749
+ * every session reads as a full context window. Two decimals is well under a pixel at this
750
+ * radius, and it keeps the markup diffable.
751
+ */
752
+ function arc(pct) {
753
+ const filled = (Math.min(100, Math.max(0, pct)) / 100) * DIAL_C;
754
+ return (`<circle class="arc" cx="40" cy="40" r="${DIAL_R}" transform="rotate(-90 40 40)"` +
755
+ ` stroke-dasharray="${round2(filled)} ${round2(DIAL_C - filled)}"/>`);
756
+ }
757
+ const round2 = (n) => Math.round(n * 100) / 100;
528
758
  function ctxCell(r) {
529
759
  if (r.ctxPct === null) {
530
- const why = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' }[r.ctxState] ?? '';
531
- return `${dash()} <span class="dim">${esc(why)}</span>`;
760
+ return `${dash()} <span class="dim">${esc(CTX_WHY[r.ctxState] ?? '')}</span>`;
532
761
  }
533
762
  // A stale reading is still the truth — of an earlier moment. Show it, and date it, with
534
763
  // the same "!" the terminal marks it with: an age in the same grey as everything else is
@@ -539,7 +768,7 @@ function ctxCell(r) {
539
768
  // "! 0m ago" — a missing measurement as a zero, contradicting itself in the same breath
540
769
  // (the "!" says past the threshold, the "0m" says brand new). The terminal path already
541
770
  // re-checked it; the two surfaces are not allowed to disagree about the module's own rule.
542
- const asOf = r.stale && r.snapshotAgeMs !== null ? ` <span class="stale">! ${esc(duration(r.snapshotAgeMs))} ago</span>` : '';
771
+ const asOf = r.stale && r.snapshotAgeMs !== null ? ` <span class="stale">! ${esc(asOfAge(r.snapshotAgeMs))} ago</span>` : '';
543
772
  return `<span class="bar"><i style="width:${Math.min(100, r.ctxPct)}%"></i></span>${r.ctxPct}%${asOf}`;
544
773
  }
545
774
  /** A partial sum is never presented as the fleet's total. */
package/dist/server.js CHANGED
@@ -14,6 +14,11 @@ import { SOURCE_PHRASE } from './config.js';
14
14
  * failures carry it too: their text is what it quotes as the reason.
15
15
  */
16
16
  const IDENTITY = { 'x-tarmac': '1' };
17
+ /** The addresses that serve the shell, and which view each one opens on. */
18
+ const PAGES = new Map([
19
+ ['/', 'table'],
20
+ ['/map', 'map'],
21
+ ]);
17
22
  export function createFleetServer({ collect }) {
18
23
  return http.createServer(async (req, res) => {
19
24
  // Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
@@ -38,7 +43,12 @@ export function createFleetServer({ collect }) {
38
43
  const url = new URL(req.url, 'http://localhost');
39
44
  // `/live` is what the open page asks for every few seconds: the same render as `/`, minus
40
45
  // the shell. Serving the whole page there would hand the running script a copy of itself.
41
- if (url.pathname !== '/' && url.pathname !== '/live' && url.pathname !== '/api/fleet') {
46
+ //
47
+ // `/map` is the same page opened on the other view, and deliberately not `/?view=map`:
48
+ // the tabs are plain links, so the view has to be somewhere a reload and a bookmark can
49
+ // both find it. There is no second fragment — one `/live` carries both views, which is
50
+ // what keeps them from ever showing readings of different ages.
51
+ if (!PAGES.has(url.pathname) && url.pathname !== '/live' && url.pathname !== '/api/fleet') {
42
52
  res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
43
53
  res.end('not found\n');
44
54
  return;
@@ -58,7 +68,7 @@ export function createFleetServer({ collect }) {
58
68
  }
59
69
  else {
60
70
  type = 'text/html; charset=utf-8';
61
- body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet);
71
+ body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet, PAGES.get(url.pathname));
62
72
  }
63
73
  }
64
74
  catch (e) {
package/dist/sessions.js CHANGED
@@ -6,9 +6,26 @@
6
6
  // Design rule carried over from the fleet's "3rd blindness": a status we do not recognise
7
7
  // is `null`, never `false`. A release that renames `busy` must make Tarmac say "I don't
8
8
  // know", not "everything is calm" — the second is a silent outage, the first is a signal.
9
+ /**
10
+ * What each word this surface prints says about the one question the boolean asks: is this
11
+ * session working. A word that does not answer it is absent, and absent means `null` — "we do
12
+ * not know" — which is the whole point of the file.
13
+ *
14
+ * The first two arrive on a session with a process of its own; the other two are a background
15
+ * agent's `state`, which is where its word lives instead.
16
+ *
17
+ * Some words are left out ON PURPOSE rather than for want of a payload. `failed` and `stopped`
18
+ * are "not working", and that is the least interesting true thing about them; `blocked` and
19
+ * `waiting` are a session halted until a human answers something, where `false` reads as calm
20
+ * on a session that needs you and `true` as fine on one that has stopped. Unknown is the only
21
+ * bucket whose node prints the word itself, so those keep it: an amber node captioned `failed`
22
+ * says what neither boolean could.
23
+ */
9
24
  const KNOWN_STATUS = new Map([
10
25
  ['busy', true],
11
26
  ['idle', false],
27
+ ['working', true],
28
+ ['done', false],
12
29
  ]);
13
30
  /** @param text raw stdout of `claude agents --json` */
14
31
  export function parseAgents(text) {
@@ -32,7 +49,10 @@ export function parseAgents(text) {
32
49
  const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
33
50
  if (!sessionId)
34
51
  health.noSessionId += 1;
35
- const status = typeof entry.status === 'string' ? entry.status : null;
52
+ // A background agent carries no `status` at all its word is under `state`. `status`
53
+ // still wins where both are present: it comes from the agent's own process, and `state`
54
+ // is what the dispatcher believes about an agent whose process may not be on this machine.
55
+ const status = typeof entry.status === 'string' ? entry.status : typeof entry.state === 'string' ? entry.state : null;
36
56
  const busy = KNOWN_STATUS.has(status) ? KNOWN_STATUS.get(status) : null;
37
57
  if (busy === null)
38
58
  health.unknownStatus += 1;
package/dist/snapshots.js CHANGED
@@ -102,8 +102,25 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
102
102
  mtimeMs = fs.statSync(file).mtimeMs;
103
103
  payload = JSON.parse(fs.readFileSync(file, 'utf8'));
104
104
  }
105
- catch {
106
- unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
105
+ catch (e) {
106
+ // ENOENT: a name listed a moment ago that resolves to nothing now. Almost always the
107
+ // sweep, deleting a cold snapshot out of the very directory we are reading — its job,
108
+ // and a race with our own housekeeping rather than a payload we failed to parse.
109
+ // Counting it made tarmac drive its own format-drift warning (up to 2675 phantom
110
+ // unreadable on one read of a 20k directory, and `list --watch` and `serve` redraw
111
+ // often enough to be inside that window).
112
+ //
113
+ // The cost, said out loud: `statSync` follows symlinks, so a DANGLING one named like a
114
+ // snapshot is ENOENT too, and it goes silent forever — a permanent state skipped as if
115
+ // it were a passing race. Deliberate. There is no payload behind a dead link either,
116
+ // and telling the two apart (an `lstat` first) buys a warning about a file `ls` already
117
+ // shows. Note it is the opposite call from `reap.ts:75`, which lstats PRECISELY so a
118
+ // dead link is not ENOENT: it deletes, and `unlink` takes a link away just fine. Reader
119
+ // and reaper ask different questions of the same shape.
120
+ //
121
+ // ENOENT only. A file we were not ALLOWED to open still counts, and must.
122
+ if (e.code !== 'ENOENT')
123
+ unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
107
124
  continue;
108
125
  }
109
126
  const t = extractTelemetry(payload);
package/dist/wrapper.js CHANGED
@@ -16,13 +16,16 @@
16
16
  // below is in the POSIX shell command language, and `test/portability.test.ts` runs this
17
17
  // script under every POSIX shell present on the machine to keep it that way.
18
18
  //
19
- // Two invariants, both tested by running the real script:
19
+ // Three invariants, all tested by running the real script:
20
20
  // RULE 1 — never break the display. Missing chain, failing chain, unwritable directory:
21
21
  // the status line still renders and the exit code is still 0. Telemetry loses,
22
22
  // display wins, always.
23
23
  // RULE 2 — never write outside the snapshot directory. `session_id` is external input
24
24
  // that becomes a filename, so anything that is not UUID-shaped is REFUSED, not
25
25
  // sanitised: a guessed name would be read back later as if it were certain.
26
+ // RULE 3 — the sweep may remove exactly what the writer may write, no more and no less.
27
+ // One rule, `SID_GLOB`, read by both — because when those two sets are merely
28
+ // written to agree, they stop agreeing quietly, in both directions at once (#7).
26
29
  /**
27
30
  * Prefix of every temp file the wrapper writes, and the ONLY thing that proves tarmac
28
31
  * wrote one. `.<sid>.<pid>.tmp` — what this used to emit — is a convention, not a
@@ -55,21 +58,54 @@ export const PRUNE_EVERY_MIN = 60;
55
58
  */
56
59
  export const SNAPSHOT_TTL_MIN = 48 * 60;
57
60
  /**
58
- * The names the sweep below is allowed to remove: the 8-4-4-4-12 session id Claude Code
59
- * emits, which is every fixture here and every file the live fleet directory holds. NOT
60
- * `*.json` that would take a `settings.json` or a `fleet.json` sitting next to them, data
61
- * this script never wrote, deleted from inside a status line.
61
+ * A session id ONE rule, and the only one the writer below, the sweep below and the
62
+ * TypeScript that reads this directory are allowed to know. Written as a shell pattern
63
+ * because two of the three consumers are shell; the third derives its regex from it.
62
64
  *
63
- * The wrapper WRITES ids wider than this on purpose (it refuses to guess what an id may look
64
- * like), so such a file is written and never pruned. That is the only direction this trade
65
- * may fail in.
65
+ * It is the canonical UUID, 8-4-4-4-12 hex: every fixture in this repo, every file in the
66
+ * snapshot directory of the fleet this was built for, every transcript file observed. The
67
+ * statusline payload documents `session_id` only as a "unique session identifier", so that
68
+ * is an observation and not a promise — and the direction of the bet is deliberate. An id
69
+ * that is not a UUID is refused at write time, which surfaces as a live session with
70
+ * `absent` telemetry: a state `fleet.ts` already names and shows. The other bet — file
71
+ * whatever arrives, and widen the deleters to match — would put every stem of 8..64
72
+ * characters of `[0-9A-Za-z-]` within reach of `rm`, in a directory whose location comes
73
+ * from `XDG_STATE_HOME` and can therefore be `~/.claude` itself, where the legacy purge
74
+ * already deletes and where people keep a git repository. A missing row is recoverable.
75
+ *
76
+ * Bracket expressions, not `?`: `?` matches a leading dot (fnmatch without FNM_PERIOD), so
77
+ * the old glob reached dotfiles the writer's own charset forbids it to produce (#7).
78
+ *
79
+ * And an ENUMERATION, not the range `[0-9a-fA-F]`: a range is collated by the locale, which
80
+ * for a status line is whatever the TUI that spawned it carries. Under `en_US.UTF-8` — the
81
+ * ordinary case on macOS, where `/bin/sh` is bash — `a-f` reaches `é`, `ç` and fullwidth `a`
82
+ * in bash, in ksh and in BSD `find`, while the regex below is ASCII code points and always
83
+ * will be. That is one string meaning two different sets depending on `LANG`, which is this
84
+ * whole rule undone: a sid filed in a Terminal, refused under `LC_ALL=C`, and a file written
85
+ * by the first frame that no TypeScript consumer here can ever recognise. Sixteen digits
86
+ * spelled out roughly doubles the pattern — 356 characters to 772 — and costs nothing
87
+ * measurable per frame.
66
88
  */
67
- export const SNAPSHOT_GLOB = '????????-????-????-????-????????????.json';
89
+ const HEX = '[0123456789abcdefABCDEF]';
90
+ export const SID_GLOB = [8, 4, 4, 4, 12].map((n) => HEX.repeat(n)).join('-');
68
91
  /**
69
- * The same set, in Node derived from the glob itself so the shell that deletes and the
70
- * TypeScript that deletes can never drift apart. `?` is any single character, `.` is literal.
92
+ * The names the sweep below is allowed to remove: the sid rule, and a `.json`. NOT `*.json`
93
+ * that would take a `settings.json` or a `fleet.json` sitting next to them, data this script
94
+ * never wrote, deleted from inside a status line.
71
95
  */
72
- export const SNAPSHOT_NAME = new RegExp(`^${SNAPSHOT_GLOB.replace(/\./g, '\\.').replace(/\?/g, '.')}$`);
96
+ export const SNAPSHOT_GLOB = `${SID_GLOB}.json`;
97
+ /**
98
+ * The same rule, in Node — the pattern goes in RAW, because a bracket expression means the
99
+ * same set in both languages and `-` is literal in both. Only the extension is spelled twice,
100
+ * once per language, which is the one thing a translation could get wrong and so the one
101
+ * thing that is written out rather than derived.
102
+ *
103
+ * `SID_NAME` is the sid alone: `fleet.ts` asks it whether a LIVE session's id is one the
104
+ * wrapper would ever file, which is the difference between "no frame drawn yet" and "no frame
105
+ * will ever help" — two states that look identical on a row.
106
+ */
107
+ export const SID_NAME = new RegExp(`^${SID_GLOB}$`);
108
+ export const SNAPSHOT_NAME = new RegExp(`^${SID_GLOB}\\.json$`);
73
109
  /** Single-quotes a string for POSIX sh. */
74
110
  function shQuote(s) {
75
111
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -108,16 +144,15 @@ case "$payload" in
108
144
  esac
109
145
  ;;
110
146
  esac
111
- # refuse anything that is not UUID-shaped — this value becomes a filename
147
+ # Refuse anything that is not a session id — this value becomes a filename, and it is the
148
+ # same rule the sweep below deletes by: what this line declines to write, that one cannot
149
+ # unlink, and the reverse. An empty sid matches nothing here, so it stays empty.
112
150
  case "$sid" in
113
- ''|*[!0-9a-zA-Z-]*) sid='' ;;
151
+ ${SID_GLOB}) ;;
152
+ *) sid='' ;;
114
153
  esac
115
154
  # refuse an ambiguous payload outright
116
155
  [ "$payload_has_two_ids" = 1 ] && sid=''
117
- if [ -n "$sid" ]; then
118
- len=\${#sid}
119
- if [ "$len" -lt 8 ] || [ "$len" -gt 64 ]; then sid=''; fi
120
- fi
121
156
 
122
157
  # --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
123
158
  if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
@@ -149,6 +184,53 @@ fi
149
184
  # frame. (A directory where the stamp itself keeps failing does pay one \`touch\` per frame,
150
185
  # forever — a fork, not a directory walk, and it means nothing can be written there anyway.)
151
186
  #
187
+ # And DETACHED, because amortized is an average and the average was never the problem: on an
188
+ # install that has never pruned, the one frame that sweeps pays for the entire backlog at
189
+ # once — a directory walk plus ten thousand unlinks on 20 000 snapshots, measured at 0.5 s in
190
+ # the report and at 0.6-0.9 s by \`test/sweep-perf.test.ts\`, in front of the status line (#8).
191
+ # Bounding the work per sweep instead would only spread that cost, at one bounded batch an
192
+ # hour, over weeks of frames that each still stop to walk the
193
+ # same directory; the frame has no business waiting for any of it. So the sweep is handed to a
194
+ # child and the frame goes on to the chain: the frame's cost becomes one fork, whatever the
195
+ # directory holds, and NOTHING on the nominal path — a frame with no sweep due — changes at all.
196
+ # What that costs, all of it on the sweep's side of the fork:
197
+ # • the child outlives the frame. It is orphaned when the shell exits and reaped by init;
198
+ # nothing waits for it, so no zombie can accumulate in the TUI's process tree.
199
+ # • if the session dies mid-sweep, the sweep normally finishes on its own: it holds no state
200
+ # but the marker, already stamped, and an orphan is not interrupted by the death of its
201
+ # parent. It does NOT go away with a Ctrl-C either — POSIX has the shell set SIGINT and
202
+ # SIGQUIT to ignored in an asynchronous list where job control is off, which is here. Only
203
+ # a signal aimed at the process group (a SIGHUP or a SIGTERM from whatever supervises the
204
+ # TUI) takes it, and then what is left is what the next hour's sweep will find, which is
205
+ # where it was heading anyway.
206
+ # • the redirections are not hygiene, they are the point. A child that inherits the frame's
207
+ # stdout puts whatever it prints INTO the status line, and holds the pipe open after this
208
+ # shell has exited — the reader waits on EOF, so the frame would still block, having only
209
+ # moved where. \`</dev/null\` is the belt to those braces: POSIX already hands an
210
+ # asynchronous list \`/dev/null\` for stdin wherever job control is off — every shell that
211
+ # will ever run this — and stdin was drained by \`\$(cat)\` at the top anyway.
212
+ # • two frames that reach the marker check together both sweep, as they always could. The
213
+ # window is not the \`touch\` — it is the \`find\` that reads the marker's age, a whole
214
+ # process, which is why two frames drawn simultaneously fork two sweeps almost every time.
215
+ # Detaching improves that case rather than widening it: what used to be two frozen frames
216
+ # is now two detached walks. What matters is that a frame drawn AFTER a sweep has started
217
+ # sees a stamped marker and starts nothing, which is the ordinary case and the reason the
218
+ # stamping stayed in the frame rather than moving into the child. Across HOURS they can
219
+ # overlap — a sweep slower than the window is joined by the
220
+ # next one — and that is harmless: \`rm -f\` on a name another sweep already unlinked is
221
+ # not an error, and the marker is the only state either of them writes.
222
+ # • a \`find\` that HANGS (a stale network mount, a directory that never answers) is no longer
223
+ # one frozen frame; it is one orphaned process per hour that nothing here reaps. The trade
224
+ # is deliberate — a frozen frame breaks RULE 1, an idle process does not — but it is
225
+ # unbounded over time in a way the blocking shape was not.
226
+ # • the chain now runs BESIDE the sweep instead of after it, so anything reading this same
227
+ # directory can watch a file vanish under it mid-frame. Nothing here breaks on that — the
228
+ # display is untouched and no data is lost — but it is not free either: \`snapshots.ts\`
229
+ # counts a file that disappears between its \`readdir\` and its \`readFile\` as UNREADABLE,
230
+ # and \`list\`/\`serve\` report that as "the schema may have moved". Same window on the
231
+ # blocking shape (a sweep and a reader have always been able to overlap), so this is not
232
+ # a regression, and it is the READER's to fix: #17.
233
+ #
152
234
  # Two known holes, both of them the safe way round, and both inherited from the fleet script
153
235
  # this transposes:
154
236
  # • a marker dated in the FUTURE (clock skew, a restored backup, a network mount) is never
@@ -189,7 +271,7 @@ if [ -d "$TARMAC_DIR" ]; then
189
271
  # The glob is the sid SHAPE, not \`*.json\`, and that is the same rule \`reap.ts\` states
190
272
  # for the temp files: only what we wrote — see SNAPSHOT_GLOB, which the legacy purge in
191
273
  # \`install.ts\` reads from the same constant.
192
- find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + 2>/dev/null
274
+ find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + >/dev/null 2>&1 </dev/null &
193
275
  fi
194
276
  fi
195
277
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",