@adrrr/tarmac 0.1.0 → 0.1.2

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,5 +1,10 @@
1
1
  # tarmac
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/%40adrrr%2Ftarmac)](https://www.npmjs.com/package/@adrrr/tarmac)
4
+ ![node](https://img.shields.io/node/v/%40adrrr%2Ftarmac)
5
+ ![runtime dependencies](https://img.shields.io/badge/runtime_dependencies-0-brightgreen)
6
+ ![license](https://img.shields.io/badge/license-MIT-blue)
7
+
3
8
  **Fleet observability for Claude Code.** One table for every session you have running —
4
9
  busy or idle, how full its context is, which model, what it has cost so far.
5
10
 
@@ -17,41 +22,6 @@ atlas idle — fresh 8h ! Opus 5 high $0.00 8h
17
22
  4 sessions · 2 busy · $127.74
18
23
  ```
19
24
 
20
- ## Why it does not break
21
-
22
- Every other way to watch a Claude Code fleet reads something Claude Code never promised
23
- would stay put: transcript files, terminal panes, undocumented paths. Those tools break on
24
- an update, and — worse — they break *quietly*, reporting a calm empty fleet.
25
-
26
- tarmac reads two things instead:
27
-
28
- | Source | What it gives | How solid |
29
- |---|---|---|
30
- | `claude agents --json` | which sessions exist, busy or idle, cwd, uptime | a documented CLI surface (`--help`: *"Print active sessions … as a JSON array … for scripting"*) |
31
- | 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** |
32
-
33
- That second line is the honest caveat, and it is the reason the real defence is not
34
- immunity, it is **visible degradation**. When a field moves, tarmac says the field moved.
35
- It never turns a measurement it could not take into a confident `0`.
36
-
37
- | What tarmac sees | What it shows |
38
- |---|---|
39
- | a percentage | the percentage |
40
- | the key is there but null | `— no turn yet` (a session that has not taken a turn) |
41
- | the key is gone or retyped | `— schema drift`, and a warning if it happened to every session |
42
- | no snapshot for a live session | `— not chained` |
43
- | a reading older than the threshold | the value, **dated** — a stale number is still true, of an earlier moment |
44
- | a status string it does not know | that string, never "idle" |
45
- | a snapshot directory it could not read | the errno, not "run tarmac install" |
46
- | a cost key that is absent | `—` for the row, and a total qualified by how many sessions really report one |
47
- | a Claude Code version no fixture covers | a notice naming that version — nothing blocked, nothing hidden |
48
-
49
- That last line is the smoke detector to the rest's alarm. The fields above were *observed*
50
- on the Claude Code builds frozen in `fixtures/`; when a session shows up on a build nobody
51
- has captured, tarmac says so **before** anything breaks, and keeps reporting. It reads the
52
- version off the payload itself, so it tells you about builds actually writing to your fleet
53
- — not about the `claude` on your PATH.
54
-
55
25
  ## Install
56
26
 
57
27
  ```bash
@@ -64,54 +34,43 @@ npx @adrrr/tarmac uninstall # put your status line back, exactly
64
34
 
65
35
  Node ≥ 20. **Zero runtime dependencies** — no framework, no bundler, nothing to audit.
66
36
 
67
- `install` and `uninstall` change a file your terminal reads on every frame, so neither runs
68
- on your say-so alone. Both print the plan first the settings file, what `statusLine` says
69
- now, what it will say, the command that is being wrapped, and the exact command that undoes
70
- it and then wait for you to **type the verb**:
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.
71
42
 
72
- ```
73
- tarmac install — your home
43
+ ## The dashboard
74
44
 
75
- file /Users/you/.claude/settings.json
76
- statusLine now ~/bin/my-line.sh
77
- statusLine next /Users/you/.claude/tarmac/statusline.sh
78
- ↳ which calls ~/bin/my-line.sh (your display is unchanged)
79
- undo tarmac uninstall
45
+ `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:
80
47
 
81
- Type "install" to proceed, anything else to abort:
48
+ ```
49
+ tarmac serving http://127.0.0.1:4477
82
50
  ```
83
51
 
84
- `y` is not an answer, and neither is silence: with stdin not a terminal a pipe, a CI job —
85
- tarmac refuses rather than read consent from an unanswerable prompt. Scripts pass `--yes`,
86
- deliberately and in writing.
87
-
88
- `--home DIR` points either command at another home; it selects a target, nothing more.
89
- **Pass the same `--home` to `list` and `serve`**, which default to yours: installing into
90
- `DIR` and then running a bare `list` reads a directory nothing was ever written to, and
91
- reports `statusline chained on 0/1 sessions` — a true statement about the wrong directory.
92
-
93
- `install` does not replace your status line, it **wraps** it: the wrapper drops the payload
94
- in `DIR/.claude/tarmac/snapshots/` and then calls whatever command was already configured,
95
- so your display is byte-identical. `uninstall` restores the original `settings.json` verbatim
96
- when you have not edited it since, and surgically (statusLine key only) when you have. If
97
- someone else has taken over the status line in the meantime, tarmac leaves it alone and
98
- tells you it restored nothing.
99
-
100
- The only thing tarmac ever deletes is its own litter: at `serve` start it removes temp
101
- files an interrupted wrapper left behind — over an hour old, and **signed**, meaning named
102
- `.tarmac-<session>.<pid>.tmp`, a name nothing but tarmac writes. It will not touch a file
103
- merely *shaped* like one of ours, because `.<name>.<pid>.tmp` is the temp-file convention of
104
- half the world. Your snapshots survive `uninstall`; they are your data.
105
-
106
- The corollary is a small one-time chore: temp files left by a **pre-signature build** of the
107
- wrapper (named `.<session>.<pid>.tmp`, without the `tarmac-` mark) are never reaped either —
108
- nothing in that name says we wrote it. If you ran one, clear them yourself, once, looking
109
- before you delete in case the directory has another writer:
52
+ It binds to loopback and refuses any request whose `Host` is not loopbackyour cwd
53
+ paths and costs never leave the machine.
110
54
 
111
- ```bash
112
- ls -l DIR/.claude/tarmac/snapshots/.*.tmp # look first
113
- rm DIR/.claude/tarmac/snapshots/.*.tmp # then remove what you recognise
114
- ```
55
+ ## Why it does not break
56
+
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.
60
+
61
+ tarmac reads two things instead:
62
+
63
+ | Source | What it gives | How solid |
64
+ |---|---|---|
65
+ | `claude agents --json` | which sessions exist, busy or idle, cwd, uptime | a documented CLI surface (`--help`: *"Print active sessions … as a JSON array … for scripting"*) |
66
+ | 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
+
68
+ 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`.
115
74
 
116
75
  ## Commands
117
76
 
@@ -123,105 +82,29 @@ rm DIR/.claude/tarmac/snapshots/.*.tmp # then remove what you recognise
123
82
  | `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
124
83
 
125
84
  `--help` works everywhere. An option handed to a command that does not read it is an
126
- **error**, not something quietly ignored — `tarmac serve --json` would otherwise parse
127
- cleanly and answer HTML anyway, which is indistinguishable from a bug.
128
-
129
- ### Staying open
130
-
131
- Both live views owe you the same two facts, and neither is allowed to be quiet about them:
132
- **when the last good reading arrived**, and **whether the last attempt to refresh failed**.
133
-
134
- The page asks the server for `/live` every 5 seconds and swaps it in; the header carries an
135
- age that keeps climbing whether or not the refresh works, so numbers that have stopped moving
136
- cannot pass for live ones. When a poll fails, a banner names the reason and the table is
137
- framed off — the data stays, because it is still true of an earlier moment. `list --watch`
138
- does the same thing in the terminal, and prints `! refresh failing — <reason>` above a table
139
- it refuses to throw away.
140
-
141
- Three failures are handled by name, because each of them can otherwise look like health:
142
-
143
- - **A refusal** (`serve` is gone) — the banner names it on the next poll.
144
- - **An empty answer.** A 200 carrying nothing is not a fleet of nothing. Swapping it in would
145
- blank the table and stamp it "updated 0s ago" with the dot still green, which is this tool's
146
- own failure mode wearing its own colours. It counts as a failed refresh.
147
- - **No answer at all.** `fetch` has no timeout in any browser, so a server that accepts the
148
- connection and goes quiet would leave the request pending forever. After 20 seconds — above
149
- the collector's own 15s timeout, so a slow-but-healthy fleet always fails server-side first
150
- with a real reason — the page gives up on it, says so, and asks again. An answer to a
151
- request it already gave up on is discarded rather than allowed to overwrite a newer one.
152
-
153
- On a terminal `--watch` redraws once a second while it waits, so the age is never more than a
154
- second out of date and a hung read shows a counter that has visibly stopped. Piped, it writes
155
- one frame per read — there is no screen to keep current, and a frame a second is just noise.
156
-
157
- It is a poll and not a meta refresh or SSE, deliberately. A meta refresh cannot render its
158
- own failure: when `serve` dies the browser throws the page away and shows its own error page,
159
- taking the one useful fact with it. SSE would hold a socket per tab and drive
160
- `claude agents --json` from a server-side timer for readers whose laptop is asleep — with a
161
- poll, a hidden tab simply stops asking, and a waking one asks at once.
162
-
163
- Everything a reader interprets is rendered on the server. The browser owns two facts and no
164
- rules: re-deriving "a dash, never a zero" in page JavaScript would put a second copy of it
165
- where the test suite cannot reach.
85
+ **error**, not something quietly ignored.
166
86
 
167
- ## Configuration
168
-
169
- Three of tarmac's numbers are opinions, not truths, so all three are yours to set. Nothing
170
- else is configurable, and every one of them keeps working with no configuration at all.
171
-
172
- | Setting | What it decides | Default |
173
- |---|---|---|
174
- | freshness threshold | how old a reading may be before it is marked `!` | `10m` |
175
- | port | where `serve` listens | `4477` |
176
- | snapshots directory | where `list` and `serve` **read** payloads from | `<home>/.claude/tarmac/snapshots` |
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`.
177
91
 
178
- **Flag beats environment beats config file beats default**, settled per setting — a port
179
- pinned in the file and a threshold tightened for one run is the normal case.
180
-
181
- | Setting | Flag | Environment | `~/.claude/tarmac/config.json` |
182
- |---|---|---|---|
183
- | freshness | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` (same spelling) | `"staleAfterMs": 90000` |
184
- | port | `--port 8080` | `TARMAC_PORT` | `"port": 8080` |
185
- | snapshots | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` |
186
-
187
- ```json
188
- { "staleAfterMs": 900000, "port": 8080 }
189
- ```
190
-
191
- `tarmac serve` opens by printing each effective value **and which of the four sources it
192
- came from**, and the freshness threshold is named in every warning that puts a `!` on a
193
- reading — a mark whose threshold is invisible is one you cannot argue with.
194
-
195
- Nothing here is ever silently dropped. A duration that will not parse, a port out of range,
196
- a key that does not exist, a file that is not JSON, a file that exists but cannot be read —
197
- each one stops the run and says what it got, where it came from, and what would have worked.
198
- **Including the ones that lose**: a broken `TARMAC_STALE_AFTER` is refused even when a flag
199
- was going to beat it, so a stale variable in a shell profile cannot lurk until the day you
200
- drop the flag.
201
-
202
- ```
203
- $ TARMAC_STALE_AFTER=soon tarmac list
204
- tarmac: TARMAC_STALE_AFTER must be a positive duration like 90s, 15m or 2h, got: soon
205
- ```
206
-
207
- A bare number is refused on purpose: `600000` is ten minutes in milliseconds and a week in
208
- seconds, and picking one for you is exactly the silent correction the rest of this tool
209
- refuses. An empty environment variable (`TARMAC_PORT= tarmac serve`) means unset, not empty.
92
+ ## Configuration
210
93
 
211
- Two edges worth knowing:
94
+ Three numbers are opinions, not truths, so all three are yours; everything else is
95
+ deliberately not configurable, and all of it works with no configuration at all.
212
96
 
213
- - **No config file is not an error** — it is the zero-config contract. `install` and
214
- `uninstall` never read the file at all, so a typo in it can never be what stands between
215
- you and putting your status line back.
216
- - The snapshots directory is a **read-side** setting, exactly like the flag it mirrors.
217
- The wrapper writes where `install` put it, under `<home>/.claude/tarmac/snapshots`. Point
218
- the reader at a directory that does not exist and tarmac says so, naming the path and the
219
- setting that sent it there — it is only the **default** being absent that means "nothing
220
- has been chained yet", because that is the one directory nobody chose.
97
+ | Setting | Flag | Environment | `~/.claude/tarmac/config.json` | Default |
98
+ |---|---|---|---|---|
99
+ | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` | `"staleAfterMs": 90000` | `10m` |
100
+ | 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` |
221
102
 
222
- The dashboard binds to loopback and refuses any request whose `Host` is not loopback —
223
- a DNS-rebinding page in your own browser would otherwise read your cwd paths and costs.
224
- That guard runs before routing, so it covers `/live` and `/api/fleet` as well as the page.
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`.
225
108
 
226
109
  ## What V1 does not do
227
110
 
@@ -243,26 +126,8 @@ npm run build # flat JavaScript into dist/
243
126
  CI runs the suite on Node 22 and 24, on Linux and macOS, with `TARMAC_REQUIRE_DASH=1` so a
244
127
  machine without dash cannot report a green build it did not earn; a separate job builds
245
128
  `dist/` and runs it on Node 20 — the oldest version `engines` promises, and the only place
246
- the published artefact is ever executed. Releases are cut by hand: see
247
- [PUBLISHING.md](PUBLISHING.md).
248
-
249
- ### Capturing a new Claude Code version
250
-
251
- When tarmac reports a version it has never checked, capture the pair — both surfaces from
252
- one build, in one command, with tarmac installed and a session of that build having drawn
253
- at least one frame:
254
-
255
- ```bash
256
- npm run fixtures:capture
257
- ```
258
-
259
- It writes `fixtures/agents-<version>.json` and `fixtures/statusline-payload-<version>-*.json`
260
- verbatim, then tells you to add the version to `CHECKED_VERSIONS` in `src/schema.ts` — the
261
- suite fails while the constant and the directory disagree, which is what keeps the guard
262
- from claiming a coverage nobody verified. **Read both files before committing, and scrub
263
- them**: they come off your machine carrying real paths, session names and costs. The
264
- fixtures in this repo are the real shapes with synthetic values, and that is the standard a
265
- new one has to meet.
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`.
266
131
 
267
132
  The suite runs the TypeScript sources directly through Node's type stripping, so it needs
268
133
  Node ≥ 22.18 to *develop*; what ships in `dist/` is plain ES2022 and runs on Node ≥ 20.
package/dist/cli.js CHANGED
@@ -11,12 +11,12 @@ import os from 'node:os';
11
11
  import { setTimeout as sleep } from 'node:timers/promises';
12
12
  import { parseArgs } from './args.js';
13
13
  import { collectFleet } from './collect.js';
14
- import { readConfigFile, resolveConfig, SOURCE_PHRASE } from './config.js';
15
- import { createFleetServer } from './server.js';
14
+ import { readConfigFile, resolveConfig } from './config.js';
15
+ import { createFleetServer, listenFleetServer } from './server.js';
16
16
  import { install, uninstall, paths, planInstall, planUninstall } from './install.js';
17
17
  import { confirmTyped } from './prompt.js';
18
18
  import { reapOrphanedTemps } from './reap.js';
19
- import { renderPlan, renderSettings, renderTable, restoreMeaning } from './render.js';
19
+ import { renderPlan, renderSettings, renderTable, restoreMeaning, servingLine } from './render.js';
20
20
  import { runWatch } from './watch.js';
21
21
  const USAGE = `tarmac — fleet observability for Claude Code
22
22
 
@@ -36,7 +36,8 @@ const USAGE = `tarmac — fleet observability for Claude Code
36
36
  --yes skip the typed confirmation — required when stdin is not a terminal
37
37
  --stale-after how old a reading may be before it is marked "!" — 90s, 15m, 2h
38
38
  (default: 10m)
39
- --port the dashboard's port (default: 4477)
39
+ --port the dashboard's port (default: 4477 — a busy default walks up to the
40
+ next free port, a port named here refuses instead)
40
41
  --snapshots-dir where the chained statusline drops its payloads
41
42
  (default: <home>/.claude/tarmac/snapshots)
42
43
  --claude-bin path to the claude CLI (default: claude)
@@ -98,7 +99,7 @@ try {
98
99
  if (args.command === 'serve') {
99
100
  // Unattended for hours, so it opens by saying what it decided and on whose authority.
100
101
  process.stdout.write(renderSettings(config, p.config));
101
- // The one place tarmac deletes anything: temp files its own wrapper left behind when a
102
+ // The one place the CLI deletes anything: temp files its own wrapper left behind when a
102
103
  // terminal died mid-write. Best effort, and it says what it did rather than doing it
103
104
  // quietly — this is the user's directory.
104
105
  const { reaped, failed } = reapOrphanedTemps(snapshotsDir);
@@ -109,17 +110,17 @@ try {
109
110
  const server = createFleetServer({
110
111
  collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source }),
111
112
  });
112
- // `listen` fails asynchronously, long after this try block has been left behind, so
113
- // its refusal needs its own way out: a port already taken is the ordinary failure of a
114
- // port pinned in a config file, and it has to read like every other refusal here.
113
+ // A port nobody chose is not worth failing over: this walks past a busy 4477 and says
114
+ // where it landed. A port that WAS chosen refuses instead, and the refusal leaves
115
+ // through the same catch as every other one here one line, naming the knob to turn.
116
+ const bound = await listenFleetServer(server, { port: config.port.value, source: config.port.source });
117
+ // Past `listen`, an error on this socket is not a port refusal and must not be printed
118
+ // as one — nor reach the top as an unhandled event: `serve` runs unattended for hours.
115
119
  server.on('error', (e) => {
116
- const why = e.code === 'EADDRINUSE' ? 'already in use' : e.message;
117
- console.error(`tarmac: cannot listen on port ${config.port.value} (${SOURCE_PHRASE[config.port.source]}) — ${why}`);
120
+ console.error(`tarmac: the dashboard stopped listening ${e.message}`);
118
121
  process.exit(1);
119
122
  });
120
- server.listen(config.port.value, '127.0.0.1', () => {
121
- console.log(`tarmac serving http://127.0.0.1:${server.address().port}`);
122
- });
123
+ console.log(servingLine(bound));
123
124
  }
124
125
  else {
125
126
  const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source });
package/dist/install.js CHANGED
@@ -252,17 +252,102 @@ export function install({ home }) {
252
252
  writeWrapper(p, backup.previous?.command ?? null, root);
253
253
  return { alreadyInstalled: true, previous: backup.previous ?? null, ...p };
254
254
  }
255
- fs.mkdirSync(p.dir, { recursive: true });
256
- fs.mkdirSync(p.snapshots, { recursive: true });
257
- writeWrapper(p, previous?.command ?? null, root);
258
- // Order matters: the backup is the only way back, so it must be on disk BEFORE
259
- // settings.json sends Claude Code to the wrapper. Crashing between the two otherwise
260
- // locks the user out of install (backup missing) and uninstall (no install found) at once.
261
- const installedText = JSON.stringify(next, null, 2) + '\n';
262
- fs.writeFileSync(p.backup, JSON.stringify({ version: 1, originalText, installedText, previous, installedAt: new Date().toISOString() }, null, 2));
263
- writeAtomic(p.settings, installedText);
255
+ // The other end of that order: everything from here CREATES, and the settings write is
256
+ // the step that can still throw — a symlinked settings.json whose target lives in a
257
+ // directory with entirely different permissions, or in one that is not there at all.
258
+ // Left behind, the wrapper and the backup describe an install that never happened, and
259
+ // that is precisely the state `uninstall` calls `foreign` and clears nothing of. So:
260
+ // remember what was already on disk, and unwind exactly what this run added.
261
+ const before = whatIsThere([p.dir, p.snapshots, p.wrapper, p.backup]);
262
+ try {
263
+ fs.mkdirSync(p.dir, { recursive: true });
264
+ fs.mkdirSync(p.snapshots, { recursive: true });
265
+ writeWrapper(p, previous?.command ?? null, root);
266
+ // Order matters: the backup is the only way back, so it must be on disk BEFORE
267
+ // settings.json sends Claude Code to the wrapper. Crashing between the two otherwise
268
+ // locks the user out of install (backup missing) and uninstall (no install found) at once.
269
+ const installedText = JSON.stringify(next, null, 2) + '\n';
270
+ fs.writeFileSync(p.backup, JSON.stringify({ version: 1, originalText, installedText, previous, installedAt: new Date().toISOString() }, null, 2));
271
+ writeAtomic(p.settings, installedText);
272
+ }
273
+ catch (failure) {
274
+ unwind(p, before);
275
+ throw failure;
276
+ }
264
277
  return { alreadyInstalled: false, previous, ...p };
265
278
  }
279
+ /**
280
+ * What is on disk before we touch it: the paths that are already there, mapped to the bytes
281
+ * we would have to put back. `null` when there are no bytes to save — a directory, a dangling
282
+ * link (whose target we would be CREATING, not restoring), a FIFO.
283
+ *
284
+ * The two stats answer two different questions, and using one for both is how this went
285
+ * wrong twice:
286
+ * • PRESENCE is `lstatSync` — the link itself. `existsSync` follows it, so a DANGLING
287
+ * symlink reads as absent and the unwind unlinks something this run never created.
288
+ * • CONTENT is `statSync` — through the link. `writeFileSync` follows it too, so when the
289
+ * dotfiles repo IS cloned the bytes this run destroys are the TARGET's: the user's own
290
+ * statusline script, which an `lstat`-only reading called "not a file" and left to die.
291
+ */
292
+ function whatIsThere(targets) {
293
+ const before = new Map();
294
+ for (const target of targets) {
295
+ try {
296
+ fs.lstatSync(target);
297
+ }
298
+ catch {
299
+ continue; // not there: ours to create, and ours to take back
300
+ }
301
+ before.set(target, statThroughLink(target)?.isFile() ? readOrNull(target) : null);
302
+ }
303
+ return before;
304
+ }
305
+ const statThroughLink = (file) => {
306
+ try {
307
+ return fs.statSync(file);
308
+ }
309
+ catch {
310
+ return null; // a dangling link: present, but with nothing behind it to save
311
+ }
312
+ };
313
+ const readOrNull = (file) => {
314
+ try {
315
+ return fs.readFileSync(file);
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ };
321
+ /**
322
+ * Put the tree back as this run found it: remove what it created, newest first, and restore
323
+ * what it overwrote. Both halves matter — a re-install that fails must not carry off the way
324
+ * back from the install that succeeded, and leaving that backup in place with `previous:
325
+ * null` written into it loses the user's statusline just as surely as deleting the file
326
+ * would. A directory that holds the user's own config.json is not ours either way.
327
+ */
328
+ function unwind(p, before) {
329
+ const attempt = (undo) => {
330
+ try {
331
+ undo();
332
+ }
333
+ catch {
334
+ // What we cannot take back must never stand in for the failure that brought us here.
335
+ }
336
+ };
337
+ const drop = (target, remove) => {
338
+ if (!before.has(target))
339
+ return attempt(() => remove(target));
340
+ const original = before.get(target) ?? null;
341
+ if (original !== null)
342
+ attempt(() => fs.writeFileSync(target, original));
343
+ };
344
+ drop(p.backup, (f) => fs.rmSync(f, { force: true }));
345
+ drop(p.wrapper, (f) => fs.rmSync(f, { force: true }));
346
+ // `rmdir`, not a recursive remove: a directory that has gained snapshots or a config since
347
+ // we made it holds someone else's data now, and ENOTEMPTY is the answer we want.
348
+ drop(p.snapshots, (d) => fs.rmdirSync(d));
349
+ drop(p.dir, (d) => fs.rmdirSync(d));
350
+ }
266
351
  /** Never let the wrapper chain to itself, whatever spelling the caller used. */
267
352
  function writeWrapper(p, chainCommand, home) {
268
353
  if (chainCommand && isWrapperCommand(chainCommand, home, p.wrapper)) {
@@ -279,14 +364,31 @@ function writeWrapper(p, chainCommand, home) {
279
364
  // restore puts the link back. So the write follows the link and lands on its target.
280
365
  // • the MODE. `-rw-------` is a decision; a restore that hands the file back
281
366
  // world-readable is not the file the user had, whatever the bytes say.
367
+ // The temp file is cleaned up even when the rename is what fails — a target the user has
368
+ // locked, or a read-only directory reached through a symlink. It lands NEXT TO their
369
+ // settings.json rather than under `~/.claude/tarmac/`, so the unwind above cannot see it and
370
+ // `reap` never looks there: nothing else would ever clear a full copy of their settings.
371
+ // After a successful rename it is already gone, and `force` makes the removal a no-op.
282
372
  function writeAtomic(file, text) {
283
373
  const target = resolveWriteTarget(file);
284
374
  const tmp = `${target}${TEMP_PREFIX}${process.pid}.tmp`;
285
- fs.writeFileSync(tmp, text);
286
- const mode = modeOf(target);
287
- if (mode !== null)
288
- fs.chmodSync(tmp, mode); // after the write: umask masks the create mode
289
- fs.renameSync(tmp, target);
375
+ try {
376
+ fs.writeFileSync(tmp, text);
377
+ const mode = modeOf(target);
378
+ if (mode !== null)
379
+ fs.chmodSync(tmp, mode); // after the write: umask masks the create mode
380
+ fs.renameSync(tmp, target);
381
+ }
382
+ finally {
383
+ // In a `finally`, so a removal that throws would REPLACE the failure that stopped the
384
+ // install — the user reading about a temp file instead of why nothing was written.
385
+ try {
386
+ fs.rmSync(tmp, { force: true });
387
+ }
388
+ catch {
389
+ // The temp file outliving the run is the smaller harm, every time.
390
+ }
391
+ }
290
392
  }
291
393
  /** The permission bits of an existing file, or `null` when we are creating it. */
292
394
  function modeOf(file) {
package/dist/reap.js CHANGED
@@ -1,4 +1,5 @@
1
- // Wrapper hygiene — the one thing tarmac deletes.
1
+ // Wrapper hygiene — the litter, which is one of the two things tarmac deletes (the other is
2
+ // the wrapper's own amortized prune of dead sessions' snapshots, see `src/wrapper.ts`).
2
3
  //
3
4
  // The generated wrapper writes `<dir>/.tarmac-<session_id>.<pid>.tmp` and renames it over
4
5
  // `<session_id>.json`, so the snapshot a reader sees is never half-written. Kill the
package/dist/render.js CHANGED
@@ -45,6 +45,18 @@ const RESTORE_MEANING = {
45
45
  absent: 'settings.json is removed — there was none before install',
46
46
  foreign: 'the statusLine is someone else\'s now, so nothing is restored and nothing is deleted',
47
47
  };
48
+ /**
49
+ * Where the dashboard is, in one line — and, when the port it was given was taken, which one
50
+ * that was.
51
+ *
52
+ * `tarmac serving ` leads in BOTH shapes, and that is a contract rather than a preference:
53
+ * two of this project's own test harnesses start `serve` and block until that substring
54
+ * appears, with no timeout behind them. A moved line that opened with anything else would not
55
+ * fail them, it would hang them — on the first day 4477 happened to be busy.
56
+ */
57
+ // Structural on purpose: `server.ts` already imports this module, so naming its `Listening`
58
+ // here would close a cycle for two numbers.
59
+ export const servingLine = ({ port, movedFrom }) => `tarmac serving http://127.0.0.1:${port}` + (movedFrom === null ? '' : ` — port ${movedFrom} was in use`);
48
60
  /**
49
61
  * What this run decided, and on whose authority. `serve` prints it once at startup because
50
62
  * it then runs unattended for hours: a threshold or a directory whose origin is invisible is
package/dist/server.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // so a read-only snapshot directory (the fleet's own, for the demo) is just a parameter.
5
5
  import http from 'node:http';
6
6
  import { reason, renderLive, renderPage } from './render.js';
7
+ import { SOURCE_PHRASE } from './config.js';
7
8
  /**
8
9
  * On every answer, including the refusals and the 500s. The page swaps what this port returns
9
10
  * into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
@@ -72,6 +73,75 @@ export function createFleetServer({ collect }) {
72
73
  res.end(body);
73
74
  });
74
75
  }
76
+ /**
77
+ * How far past a port NOBODY CHOSE `serve` may walk. A corridor, not a search: wide enough
78
+ * to step over a dashboard someone left running, narrow enough that the refusal at the end
79
+ * of it can still name every port it tried.
80
+ */
81
+ export const PORT_FALLBACK_TRIES = 10;
82
+ /**
83
+ * Binds the dashboard, and says where it ended up.
84
+ *
85
+ * Rejects rather than exiting: `serve`'s refusals all leave through one catch, as one line
86
+ * naming the knob to turn, and a `listen` that failed on an event three ticks later used to
87
+ * be the single exception to that.
88
+ *
89
+ * It resolves with NO `error` listener left on the server — every one it attached is its own
90
+ * and comes off. A caller that then awaits anything before attaching one is a caller whose
91
+ * next socket error is an unhandled event.
92
+ */
93
+ export async function listenFleetServer(server, { port, source, host = '127.0.0.1' }) {
94
+ const walks = source === 'default';
95
+ // `resolveConfig` reports `default` for one number only — 4477 — so the far end of the
96
+ // corridor cannot fall outside the legal range of a port.
97
+ const last = walks ? port + PORT_FALLBACK_TRIES : port;
98
+ for (let candidate = port;; candidate++) {
99
+ const failure = await attempt(server, candidate, host);
100
+ // The socket, never the number asked for: port 0 is legal and means "pick a free one",
101
+ // and answering `0` there sent `serve` to print a URL nobody can open.
102
+ if (failure === null) {
103
+ return { port: server.address().port, movedFrom: candidate === port ? null : port };
104
+ }
105
+ // A taken port is the only failure the next port can fix. A privileged port, an address
106
+ // this machine does not have, a descriptor limit — none of them get better ten ports
107
+ // later, and reporting them as "all in use" sends the user hunting a process that was
108
+ // never there.
109
+ if (failure.code === 'EADDRINUSE' && candidate < last)
110
+ continue;
111
+ if (failure.code === 'EADDRINUSE' && walks) {
112
+ throw new Error(`ports ${port}-${last} all in use — pick one with --port <n>`);
113
+ }
114
+ // The source chose `port`. If the walk has moved on, saying "4479 (the default)" would be
115
+ // false — 4479 is a port this module picked, and this module's whole discipline is that a
116
+ // value is reported with whoever actually chose it.
117
+ const whose = candidate === port ? SOURCE_PHRASE[source] : `walked to from ${port}, ${SOURCE_PHRASE[source]}`;
118
+ const why = failure.code === 'EADDRINUSE' ? 'already in use, --port <n> picks another' : failure.message;
119
+ throw new Error(`cannot listen on port ${candidate} (${whose}) — ${why}`);
120
+ }
121
+ }
122
+ /**
123
+ * One bind. Resolves with the error instead of rejecting, so "busy, try the next one" and
124
+ * "this run is over" stay two different things at the call site.
125
+ *
126
+ * Both listeners come off before it settles: `listen` reports through events, and the same
127
+ * server object is reused for every attempt — a handler left behind would answer for the
128
+ * attempt after it.
129
+ */
130
+ function attempt(server, port, host) {
131
+ return new Promise((resolve) => {
132
+ const onError = (e) => {
133
+ server.removeListener('listening', onListening);
134
+ resolve(e);
135
+ };
136
+ const onListening = () => {
137
+ server.removeListener('error', onError);
138
+ resolve(null);
139
+ };
140
+ server.once('error', onError);
141
+ server.once('listening', onListening);
142
+ server.listen(port, host);
143
+ });
144
+ }
75
145
  function isLoopbackHost(host) {
76
146
  if (!host)
77
147
  return false;
package/dist/wrapper.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // P2 — the generated statusline wrapper.
2
2
  //
3
3
  // Claude Code calls `statusLine.command` on EVERY frame of its TUI, passing a documented
4
- // JSON payload on stdin. The wrapper does exactly two things:
4
+ // JSON payload on stdin. The wrapper does exactly three things:
5
5
  // 1. drop that payload as-is under <snapshotDir>/<session_id>.json (the telemetry);
6
- // 2. hand stdin to the command that was already configured, so the user's display is
6
+ // 2. prune the snapshots of sessions that stopped rendering — amortized, because every
7
+ // line here is paid on every frame (see PRUNE_MARKER below);
8
+ // 3. hand stdin to the command that was already configured, so the user's display is
7
9
  // untouched.
8
10
  //
9
11
  // Written as POSIX sh rather than Node on purpose: this sits in the render path of every
@@ -38,6 +40,20 @@ export const TEMP_PREFIX = '.tarmac-';
38
40
  * is, in itself, whatever the spelling that reached it.
39
41
  */
40
42
  export const WRAPPER_MARKER = 'tarmac statusline wrapper — GENERATED';
43
+ /**
44
+ * Bookkeeping file of the amortized prune below — its mtime is the date of the last sweep.
45
+ * A dotfile, because `readSnapshots` skips those: the wrapper's own paperwork must never be
46
+ * read back as if it were a session's telemetry.
47
+ */
48
+ export const PRUNE_MARKER = '.tarmac-last-prune';
49
+ /** At most one sweep per hour, whatever the frame rate. R3's number, and its whole point. */
50
+ export const PRUNE_EVERY_MIN = 60;
51
+ /**
52
+ * How long a snapshot nobody rewrites survives. A live session restamps its own file on
53
+ * every frame, so 48h without one is a session that is gone — and far beyond `--stale-after`,
54
+ * so nothing was reading that file as current anyway.
55
+ */
56
+ export const SNAPSHOT_TTL_MIN = 48 * 60;
41
57
  /** Single-quotes a string for POSIX sh. */
42
58
  function shQuote(s) {
43
59
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -90,13 +106,82 @@ fi
90
106
  # --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
91
107
  if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
92
108
  tmp="$TARMAC_DIR/${TEMP_PREFIX}$sid.$$.tmp"
93
- if printf '%s\\n' "$payload" > "$tmp" 2>/dev/null; then
109
+ # \`2>/dev/null\` comes FIRST, and the order is the whole point: redirections are applied
110
+ # left to right, so \`> "$tmp" 2>/dev/null\` opens the temp file while stderr is STILL the
111
+ # user's terminal — the shell prints its own \`cannot create …: Permission denied\` there,
112
+ # and the \`2>\` that was meant to swallow it only takes effect afterwards. On a snapshot
113
+ # directory that has become read-only that is one line of noise per FRAME, on the terminal
114
+ # of a script whose first rule is to be invisible. Exit code and display are untouched
115
+ # (\`printf\` is a regular built-in, so a failed redirection only fails the command), which
116
+ # is exactly why nothing but stderr itself catches this. RULE 1.
117
+ if printf '%s\\n' "$payload" 2>/dev/null > "$tmp"; then
94
118
  mv -f "$tmp" "$TARMAC_DIR/$sid.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
95
119
  else
96
120
  rm -f "$tmp" 2>/dev/null
97
121
  fi
98
122
  fi
99
123
 
124
+ # --- prune the snapshots of sessions that stopped rendering (amortized) ---
125
+ # Nothing else would ever remove them: \`reap.ts\` collects this script's own temp litter and
126
+ # refuses to touch a \`<sid>.json\`. A fleet that recycles its sessions nightly leaves one dead
127
+ # file behind per session per night, forever. A LIVE session restamps its own snapshot on
128
+ # every frame, so mtime is what tells the dead from the living.
129
+ #
130
+ # Amortized, because this is the render path: the frame pays one \`find\` on ONE file — the
131
+ # marker — and the DIRECTORY is walked at most once an hour. The marker is stamped BEFORE the
132
+ # sweep and only if stamping works, so a sweep that cannot finish is not retried on the next
133
+ # frame. (A directory where the stamp itself keeps failing does pay one \`touch\` per frame,
134
+ # forever — a fork, not a directory walk, and it means nothing can be written there anyway.)
135
+ #
136
+ # Two known holes, both of them the safe way round, and both inherited from the fleet script
137
+ # this transposes:
138
+ # • a marker dated in the FUTURE (clock skew, a restored backup, a network mount) is never
139
+ # stale, so pruning stops until the wall clock catches up. Silent, and it fails towards
140
+ # "keep files" rather than "delete files".
141
+ # • \`-mmin\` is the one thing here POSIX does not require. GNU, BSD and toybox find have it,
142
+ # busybox has it unless the build dropped CONFIG_FEATURE_FIND_MMIN; \`-mtime\` could not
143
+ # replace it (it cannot express hours, and it rounds UP on BSD and DOWN on GNU, so one
144
+ # expression would mean 24h on macOS and 48h on Linux). Where it is missing \`find\` fails,
145
+ # the substitution is empty, and the sweep simply never runs.
146
+ # In both cases snapshots pile up exactly as they did before this block existed, and the
147
+ # display is never at risk.
148
+ #
149
+ # One race, priced and accepted: a sweep can stat a cold snapshot in the microseconds before
150
+ # another session's frame renames a fresh one over that same name, and then unlink the fresh
151
+ # inode. The session writes another one on its next frame — seconds later, on a live session
152
+ # — whereas a lock would cost every frame of every session, forever.
153
+ if [ -d "$TARMAC_DIR" ]; then
154
+ marker="$TARMAC_DIR/${PRUNE_MARKER}"
155
+ # Only a plain file, or nothing at all, is a marker — and the braces are load-bearing,
156
+ # since \`&&\` and \`||\` have EQUAL precedence in sh. Both refusals are real:
157
+ # • a symlink — \`touch\` follows it, so a dangling one has the status line CREATE the
158
+ # link's target, a file outside the snapshot directory. RULE 2.
159
+ # • a directory — \`touch\` SUCCEEDS on one, so every frame reads as freshly stamped while
160
+ # \`-mmin\` never gets a regular file to judge: the sweep would run on every frame and
161
+ # the amortization would be gone, silently.
162
+ if [ ! -h "$marker" ] && { [ ! -e "$marker" ] || { [ -f "$marker" ] && [ -n "$(find "$marker" -mmin +${PRUNE_EVERY_MIN} 2>/dev/null)" ]; }; }; then
163
+ # \`touch\`, and NOT \`: > "$marker"\`: POSIX says a redirection error on a special
164
+ # built-in — and \`:\` is one — shall abort a non-interactive shell. On a snapshot
165
+ # directory that has become read-only that spelling exits dash 2 and ksh 1 with the
166
+ # status line never printed, while bash carries on: RULE 1, broken on Debian and Ubuntu
167
+ # only. An ordinary utility just reports a non-zero status, which is what this \`if\` is
168
+ # asking about.
169
+ if touch "$marker" 2>/dev/null; then
170
+ # \`<dir>/.\` with \`! -name . -prune\` is how POSIX spells \`-maxdepth 1\`, and \`-type f\`
171
+ # keeps a directory or a symlink wearing a session id's name out of it.
172
+ #
173
+ # The glob is the sid SHAPE, not \`*.json\`, and that is the same rule \`reap.ts\` states
174
+ # for the temp files: only what we wrote. A bare \`*.json\` would take \`settings.json\`
175
+ # or \`fleet.json\` with it — data this script never wrote, deleted from inside a status
176
+ # line. 8-4-4-4-12 is the UUID Claude Code emits (every fixture here, and the live
177
+ # fleet directory). The wrapper accepts wider ids than that because it refuses to guess
178
+ # what an id may look like; a sid outside this shape is therefore written and never
179
+ # pruned, which is the direction this trade has to fail in.
180
+ find "$TARMAC_DIR"/. ! -name . -prune -name '????????-????-????-????-????????????.json' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + 2>/dev/null
181
+ fi
182
+ fi
183
+ fi
184
+
100
185
  # --- hand over to the status line that was already there ---
101
186
  if [ -n "$TARMAC_CHAIN" ]; then
102
187
  printf '%s\\n' "$payload" | sh -c "$TARMAC_CHAIN"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",
@@ -19,7 +19,7 @@
19
19
  "node": ">=20"
20
20
  },
21
21
  "bin": {
22
- "tarmac": "./dist/cli.js"
22
+ "tarmac": "dist/cli.js"
23
23
  },
24
24
  "files": [
25
25
  "dist"