@adrrr/tarmac 0.1.2 → 0.2.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
@@ -12,14 +12,14 @@ busy or idle, how full its context is, which model, what it has cost so far.
12
12
  $ npx @adrrr/tarmac list
13
13
 
14
14
  PROJECT STATE CTX AS OF MODEL EFFORT COST UP
15
- apollo busy 28% 7m Fable 5 max $53.98 8h
16
- mercury-dashboard busy 27% 22m ! Fable 5 max $70.62 8h
17
- gemini idle 12% 3h ! Opus 5 high $3.14 8h
15
+ apollo busy 28% 7m Fable 5 max $41.20 8h
16
+ mercury-dashboard busy 27% 22m ! Fable 5 max $62.75 8h
17
+ gemini idle 12% 3h ! Opus 5 high $5.40 8h
18
18
  atlas idle — fresh 8h ! Opus 5 high $0.00 8h
19
19
 
20
20
  ! 3 reading(s) marked "!" are older than the freshness threshold
21
21
 
22
- 4 sessions · 2 busy · $127.74
22
+ 4 sessions · 2 busy · $109.35
23
23
  ```
24
24
 
25
25
  ## Install
@@ -40,6 +40,11 @@ it prints the full plan — including the exact command that undoes it — and w
40
40
  line is wrapped, not replaced: the display stays byte-identical, and `uninstall` restores
41
41
  the original exactly.
42
42
 
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.
47
+
43
48
  ## The dashboard
44
49
 
45
50
  `tarmac serve` puts the same fleet in the browser — every session a row, ages that keep
@@ -98,7 +103,7 @@ deliberately not configurable, and all of it works with no configuration at all.
98
103
  |---|---|---|---|---|
99
104
  | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` | `"staleAfterMs": 90000` | `10m` |
100
105
  | 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` |
106
+ | snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | `$XDG_STATE_HOME/tarmac/snapshots`, else `<home>/.local/state/tarmac/snapshots` |
102
107
 
103
108
  **Flag beats environment beats config file beats default**, settled per setting. `serve`
104
109
  opens by printing each effective value and which source it came from. Nothing is ever
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from './args.js';
13
13
  import { collectFleet } from './collect.js';
14
14
  import { readConfigFile, resolveConfig } from './config.js';
15
15
  import { createFleetServer, listenFleetServer } from './server.js';
16
- import { install, uninstall, paths, planInstall, planUninstall } from './install.js';
16
+ import { install, uninstall, paths, planInstall, planUninstall, installedSnapshotsDir, wrapperIsOurs } from './install.js';
17
17
  import { confirmTyped } from './prompt.js';
18
18
  import { reapOrphanedTemps } from './reap.js';
19
19
  import { renderPlan, renderSettings, renderTable, restoreMeaning, servingLine } from './render.js';
@@ -39,7 +39,8 @@ const USAGE = `tarmac — fleet observability for Claude Code
39
39
  --port the dashboard's port (default: 4477 — a busy default walks up to the
40
40
  next free port, a port named here refuses instead)
41
41
  --snapshots-dir where the chained statusline drops its payloads
42
- (default: <home>/.claude/tarmac/snapshots)
42
+ (default: $XDG_STATE_HOME/tarmac/snapshots, or
43
+ <home>/.local/state/tarmac/snapshots)
43
44
  --claude-bin path to the claude CLI (default: claude)
44
45
 
45
46
  Those three settings can also be set, in decreasing order of precedence, by the
@@ -78,6 +79,11 @@ try {
78
79
  console.log(res.alreadyInstalled
79
80
  ? `install: already installed — wrapper regenerated, settings.json left alone`
80
81
  : `install: statusLine wrapped — undo with \`${plan.undo}\``);
82
+ // What the plan promised, as it actually went: a frame of the OLD wrapper can land in
83
+ // that directory between the two, so the count is reported rather than assumed.
84
+ if (res.legacy !== null)
85
+ console.log(`install: cleared ${res.legacy.payloads} runtime payload(s) from ${res.legacy.dir} — they belong in ${res.snapshots}` +
86
+ (res.legacy.kept > 0 ? ` (${res.legacy.kept} file(s) kept, so the directory stays)` : ''));
81
87
  }
82
88
  else {
83
89
  const { mode } = uninstall({ home });
@@ -88,11 +94,20 @@ try {
88
94
  // Only the reading commands resolve settings, and only they read the config file: a
89
95
  // typo in it must not be what stands between a user and `tarmac uninstall`.
90
96
  const p = paths(args.home ?? os.homedir());
97
+ // The installed wrapper's own path, when there is one: the default is where the WRITER
98
+ // writes, not where a reader's environment would have put it. Recomputing it here made
99
+ // `XDG_STATE_HOME` in one process and not the other a silent split.
100
+ const frozen = installedSnapshotsDir(p);
101
+ if (frozen === null && wrapperIsOurs(p))
102
+ console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
91
103
  const config = resolveConfig({
92
104
  flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir },
93
105
  env: process.env,
94
106
  file: readConfigFile(p.config),
95
- defaultSnapshotsDir: p.snapshots,
107
+ // The installed wrapper's own path, when there is one: the default is where the
108
+ // WRITER writes, not where a reader's environment would have put it. Recomputing it
109
+ // here made `XDG_STATE_HOME` in one process and not the other a silent split.
110
+ defaultSnapshotsDir: frozen ?? p.snapshots,
96
111
  });
97
112
  const snapshotsDir = config.snapshotsDir.value;
98
113
  const staleAfterMs = config.staleAfterMs.value;
@@ -108,7 +123,7 @@ try {
108
123
  if (failed > 0)
109
124
  console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
110
125
  const server = createFleetServer({
111
- collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source }),
126
+ collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
112
127
  });
113
128
  // A port nobody chose is not worth failing over: this walks past a busy 4477 and says
114
129
  // where it landed. A port that WAS chosen refuses instead, and the refusal leaves
@@ -123,7 +138,7 @@ try {
123
138
  console.log(servingLine(bound));
124
139
  }
125
140
  else {
126
- const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source });
141
+ const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null });
127
142
  if (args.watch) {
128
143
  // One redraws a screen, the other is meant to be piped once. Silently letting one win
129
144
  // is how someone ends up parsing a frame of terminal art.
package/dist/collect.js CHANGED
@@ -3,22 +3,30 @@ import { SOURCE_PHRASE } from './config.js';
3
3
  import { discoverSessions } from './discover.js';
4
4
  import { readSnapshots } from './snapshots.js';
5
5
  import { buildFleet } from './fleet.js';
6
- export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', }) {
6
+ export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', installed = false, }) {
7
7
  const { sessions, health: discovery } = await discoverSessions({ claudeBin });
8
8
  const { snapshots, dirError, unreadable, duplicates, dirMissing } = readSnapshots(snapshotsDir, { now });
9
9
  const fleet = buildFleet({ sessions, snapshots, now, discovery, staleAfterMs });
10
10
  // Both blind spots travel with the data: a directory we could not read and files we
11
11
  // could not parse are OUR failures to report, not silence to render as "all clear".
12
12
  //
13
- // An absent directory is judged HERE, because this is the layer that knows who chose it:
14
- // the default may simply not exist yet (nothing has been chained), but a path someone
15
- // typed flag, environment, or a config file edited months ago that is not there is a
16
- // setting pointing at nothing. Rendered as "not chained yet" it sends the user to run
17
- // `tarmac install`, which cannot fix it: install writes where install writes.
18
- fleet.health.snapshotsError =
19
- dirMissing && snapshotsDirSource !== 'default'
13
+ // An absent directory is judged HERE, because this is the layer that knows who chose it.
14
+ // A path someone typed flag, environment, or a config file edited months ago — that is
15
+ // not there is a setting pointing at nothing; rendered as "not chained yet" it sends the
16
+ // user to run `tarmac install`, which cannot fix it: install writes where install writes.
17
+ //
18
+ // Exactly ONE case is innocent, and it is narrower than it used to be: no install here at
19
+ // all. Since #20 the default is the path the install FROZE into the wrapper — a directory
20
+ // that was chosen and created by a run that happened — so its absence means the writer and
21
+ // the reader have parted company, which renders as a healthy, empty fleet. That is the one
22
+ // failure this tool may not have.
23
+ fleet.health.snapshotsError = !dirMissing
24
+ ? dirError
25
+ : snapshotsDirSource !== 'default'
20
26
  ? `ENOENT: ${snapshotsDir} does not exist — set by ${SOURCE_PHRASE[snapshotsDirSource]}`
21
- : dirError;
27
+ : installed
28
+ ? `ENOENT: ${snapshotsDir} does not exist — the installed wrapper writes there`
29
+ : dirError;
22
30
  fleet.health.snapshotsUnreadable = unreadable;
23
31
  fleet.health.snapshotsDuplicates = duplicates;
24
32
  fleet.health.snapshotsDir = snapshotsDir;
package/dist/install.js CHANGED
@@ -22,25 +22,72 @@ import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { chainStatusLine, unchainStatusLine } from './settings.js';
24
24
  import { firstWord, quoteArg } from './shell.js';
25
- import { renderWrapper, TEMP_PREFIX, WRAPPER_MARKER } from './wrapper.js';
26
- export function paths(home) {
25
+ import { renderWrapper, PRUNE_MARKER, SNAPSHOT_NAME, TEMP_PREFIX, WRAPPER_MARKER } from './wrapper.js';
26
+ export function paths(home, { env = process.env, realHome } = {}) {
27
27
  const claude = path.join(home, '.claude');
28
28
  const dir = path.join(claude, 'tarmac');
29
+ const stateDir = path.join(stateRoot(home, env, realHome), 'tarmac');
29
30
  return {
30
31
  claude,
31
32
  settings: path.join(claude, 'settings.json'),
32
33
  dir,
33
34
  wrapper: path.join(dir, 'statusline.sh'),
34
35
  backup: path.join(dir, 'backup.json'),
35
- snapshots: path.join(dir, 'snapshots'),
36
+ snapshots: path.join(stateDir, 'snapshots'),
37
+ stateDir,
38
+ legacySnapshots: path.join(dir, 'snapshots'),
36
39
  config: path.join(dir, 'config.json'),
37
40
  };
38
41
  }
42
+ /**
43
+ * `$XDG_STATE_HOME`, or `<home>/.local/state` — the XDG default, for "state data that should
44
+ * persist between restarts but is not important enough for the data directory". A snapshot
45
+ * is exactly that: a reading of one frame, rewritten by the next.
46
+ *
47
+ * The environment is only honoured FOR THE HOME THAT EXPORTED IT, which is the same rule
48
+ * `commandTarget` applies to a `~` in someone's statusLine: `--home` exists to work on
49
+ * someone else's `.claude`, and this process's `XDG_STATE_HOME` says nothing about theirs.
50
+ * Read unconditionally, `tarmac install --home /home/jane` would point jane's wrapper at our
51
+ * state directory. (It is NOT what keeps this project's own suite out of the developer's
52
+ * real one — every CLI test there replaces $HOME, so both anchors coincide by construction
53
+ * and this guard reads true. Removing the variable is the test helper's job, and it does it.)
54
+ *
55
+ * A relative value is ignored, as the spec asks: it would resolve against the working
56
+ * directory, which for a status line is wherever Claude Code happened to be started.
57
+ */
58
+ function stateRoot(home, env, realHome) {
59
+ const xdg = env.XDG_STATE_HOME?.trim();
60
+ // Short-circuited on purpose: the real home is a QUESTION ABOUT THIS VARIABLE. Asking it
61
+ // unconditionally — as a default parameter — made `paths()`, which never touched the
62
+ // environment before, able to throw on a container with no passwd entry and no $HOME.
63
+ if (xdg && path.isAbsolute(xdg) && sameFile(home, thisHome(realHome)))
64
+ return xdg;
65
+ return path.join(home, '.local', 'state');
66
+ }
67
+ /**
68
+ * Which home this process runs under, or `null` when the system cannot say. Unanswerable
69
+ * reads as "this is NOT the home that exported the variable" — the safe direction: the
70
+ * default under the home actually being targeted, rather than a throw or someone else's
71
+ * directory.
72
+ */
73
+ function thisHome(realHome) {
74
+ if (realHome !== undefined)
75
+ return realHome;
76
+ try {
77
+ return os.homedir();
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
39
83
  // Two paths name the same directory far more often than string equality admits: `/tmp` is
40
84
  // a symlink to `/private/tmp`, `/System/Volumes/Data/Users/x` is a macOS firmlink onto
41
85
  // `/Users/x` (same inode, and `realpath` does NOT collapse it), plus bind mounts and
42
86
  // relative spellings. Device + inode is the only identity that holds through all of them.
43
87
  function sameFile(a, b) {
88
+ // `null` is "the question could not be answered" — never an accidental match.
89
+ if (b === null)
90
+ return false;
44
91
  try {
45
92
  const sa = fs.statSync(a);
46
93
  const sb = fs.statSync(b);
@@ -135,6 +182,173 @@ function writesInstead(file) {
135
182
  const target = resolveWriteTarget(file);
136
183
  return target === file ? null : target;
137
184
  }
185
+ // ── the payloads left inside `.claude` by the versions that wrote them there ────────────
186
+ //
187
+ // Moving the directory is only half of #20: the machines that hit the bug already have the
188
+ // files, committed, and nothing would ever remove them. They are PURGED rather than moved —
189
+ // a snapshot is a reading of the frame that wrote it, every live session writes a fresh one
190
+ // within seconds, and carrying them across would import into the new directory the very
191
+ // files the issue is about, dated from before the move.
192
+ /**
193
+ * What is in the legacy directory: the payloads this tool wrote, and everything else.
194
+ *
195
+ * The "ours" set is the WRITER'S RULE, not merely the writer's names. The wrapper's own
196
+ * sweep is `-name '<sid shape>' -type f`, and both halves are the rule — `wrapper.ts` refuses
197
+ * a directory or a symlink wearing a session id's name, because a name is not provenance.
198
+ * Matching only the name here removed a symlink the shell sweep would have left. Everything
199
+ * else is someone's, and one of them is enough to keep the directory (`rmdir`, never a
200
+ * recursive remove: the same rule the unwind states, and this one runs inside a git repo).
201
+ */
202
+ function readLegacyDir(p) {
203
+ // The directory the wrapper is ABOUT TO WRITE TO is never the directory we clear, however
204
+ // the two came to be the same path — `XDG_STATE_HOME=$HOME/.claude` is enough. Purging it
205
+ // would delete the payloads while announcing the very same path as their new home.
206
+ if (sameFile(p.snapshots, p.legacySnapshots))
207
+ return null;
208
+ const dir = p.legacySnapshots;
209
+ // `lstat`, not `readdir` alone: a SYMLINK here is the workaround someone will already have
210
+ // applied to #20 — the directory pointed at a disk outside the repo. `readdir` follows it,
211
+ // so an unguarded sweep would delete their snapshots at the far end and leave the link.
212
+ // A directory this tool did not make is not this tool's to empty.
213
+ try {
214
+ if (!fs.lstatSync(dir).isDirectory())
215
+ return null;
216
+ }
217
+ catch {
218
+ return null; // absent, which is the normal case from #20 on
219
+ }
220
+ let entries;
221
+ try {
222
+ entries = fs.readdirSync(dir);
223
+ }
224
+ catch {
225
+ // Unreadable is not ours to fix from inside an install.
226
+ return null;
227
+ }
228
+ const ours = [];
229
+ let kept = 0;
230
+ for (const name of entries) {
231
+ if (isPayloadName(name) && isPlainFile(path.join(dir, name)))
232
+ ours.push(name);
233
+ else
234
+ kept += 1;
235
+ }
236
+ return { ours, kept };
237
+ }
238
+ /**
239
+ * The three names the wrapper writes into its snapshot directory, and nothing else.
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.
246
+ */
247
+ 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) => {
250
+ try {
251
+ return fs.lstatSync(file).isFile();
252
+ }
253
+ catch {
254
+ return false;
255
+ }
256
+ };
257
+ /**
258
+ * Did an install of OURS already exist here, before this run wrote anything?
259
+ *
260
+ * `~/.claude/tarmac/snapshots` is a documented path — this project's own docs invite you to
261
+ * point a reader at one — and the "ours" set is a SHAPE: a UUID name, a `.tarmac-` prefix.
262
+ * Shape is not provenance. Without this, a FIRST install on a home tarmac had never touched
263
+ * deleted another writer's files, under a plan promising they would be "written again on the
264
+ * next frame" by a wrapper that had never written them. It is the same reasoning that spares
265
+ * a symlink two functions up, applied where it was missing.
266
+ *
267
+ * Three independent proofs, any one of which is enough: the statusLine already points at us
268
+ * (the update path), our marker is in the wrapper (an install whose settings.json was lost),
269
+ * or a usable backup is on disk (a wrapper someone deleted by hand). All three must be read
270
+ * BEFORE this run writes anything — by the time the purge runs it has written two of them.
271
+ */
272
+ export function tarmacWasInstalledHere(p, alreadyInstalled) {
273
+ return alreadyInstalled || carriesWrapperMarker(p.wrapper) || isUsableBackup(readBackup(p));
274
+ }
275
+ /** Read-only, for the plan: what an install would clear, before a byte is written. */
276
+ export function countLegacySnapshots(p, wasInstalled) {
277
+ if (!wasInstalled)
278
+ return null;
279
+ const found = readLegacyDir(p);
280
+ return found === null ? null : { dir: p.legacySnapshots, payloads: found.ours.length, kept: found.kept };
281
+ }
282
+ /**
283
+ * …and the deed. Best effort throughout: a payload we cannot remove is counted as KEPT, not
284
+ * as removed — which is both honest and exactly what decides whether the directory goes.
285
+ *
286
+ * Runs LAST in an install, after the wrapper has been rewritten: until those bytes land, the
287
+ * OLD wrapper is still the one Claude Code calls, and still dropping a file in here at every
288
+ * frame. That frame cannot be locked out — and its first act is `mkdir -p`, so it can put the
289
+ * whole directory back between the `rmdir` and this function returning. Hence the read-back:
290
+ * what is reported is what is ON DISK afterwards, never what was asked for. A user told to
291
+ * commit a removal that had already been undone is worse served than one told it did not take.
292
+ */
293
+ export function purgeLegacySnapshots(p, wasInstalled) {
294
+ if (!wasInstalled)
295
+ return null;
296
+ const found = readLegacyDir(p);
297
+ if (found === null)
298
+ return null;
299
+ let payloads = 0;
300
+ let kept = found.kept;
301
+ for (const name of found.ours) {
302
+ try {
303
+ fs.unlinkSync(path.join(p.legacySnapshots, name));
304
+ payloads += 1;
305
+ }
306
+ catch {
307
+ kept += 1;
308
+ }
309
+ }
310
+ if (kept === 0) {
311
+ // Twice at most. The first `rmdir` can lose a race with a frame's `mkdir -p`, and what
312
+ // that recreates is an EMPTY directory — which the second attempt takes. A second failure
313
+ // means the frame also wrote a payload into it: reported, not chased, because this is an
314
+ // install and not a daemon. Either way what is reported is read back from disk.
315
+ for (let pass = 0; pass < 2; pass++) {
316
+ try {
317
+ fs.rmdirSync(p.legacySnapshots);
318
+ }
319
+ catch {
320
+ // ENOTEMPTY from a frame that landed mid-sweep — the read-back below reports it.
321
+ }
322
+ const after = readLegacyDir(p);
323
+ if (after === null)
324
+ break; // gone, which is the whole point
325
+ kept = after.ours.length + after.kept;
326
+ }
327
+ }
328
+ return { dir: p.legacySnapshots, payloads, kept };
329
+ }
330
+ /**
331
+ * The version-controlled directory this install writes into, nearest first — `.claude` under
332
+ * its own repo, else a home that is one (`git init ~` is an ordinary dotfiles setup, and the
333
+ * hint was silent for it while the purge ran just the same). Nearest wins, so the advice is
334
+ * always about the repository the files are actually in.
335
+ *
336
+ * `.git` is a FILE in a worktree or a submodule — a `gitdir:` pointer — and the people who
337
+ * keep `.claude` in a dotfiles repo are exactly the people who use those, so this asks
338
+ * whether the name exists at all rather than what it is. A bare-repo setup (`yadm`, a
339
+ * `--git-dir` alias) has no `.git` anywhere and is not detected: stated, not papered over.
340
+ *
341
+ * Only ever used to SAY something. Nothing here reads, writes or runs git.
342
+ */
343
+ function gitRepoOf(p, home) {
344
+ const dir = [p.claude, home].find((d) => fs.existsSync(path.join(d, '.git')));
345
+ if (dir === undefined)
346
+ return null;
347
+ // Relative to the repository that will carry the `.gitignore`, with a trailing slash so it
348
+ // names a directory. A fixed `tarmac/snapshots/` was right for `.claude` and INERT for a
349
+ // home — `git check-ignore` says so, and a test now asks it rather than asking us.
350
+ return { dir, ignore: `${path.relative(dir, p.legacySnapshots)}/` };
351
+ }
138
352
  export function planInstall({ home, realHome = os.homedir() }) {
139
353
  const root = requireHome(home);
140
354
  const p = paths(root);
@@ -158,9 +372,27 @@ export function planInstall({ home, realHome = os.homedir() }) {
158
372
  after: alreadyInstalled ? before : quoteArg(p.wrapper),
159
373
  chained: alreadyInstalled ? (backupOrRefuse(p).previous?.command ?? null) : (previous?.command ?? null),
160
374
  alreadyInstalled,
375
+ snapshots: p.snapshots,
376
+ legacy: countLegacySnapshots(p, tarmacWasInstalledHere(p, alreadyInstalled)),
377
+ gitRepo: gitRepoOf(p, root),
378
+ movingFrom: movedFrom(p),
161
379
  undo: undoCommand('uninstall', root, isRealHome),
162
380
  };
163
381
  }
382
+ /**
383
+ * The directory the installed wrapper writes to today, when this install is about to freeze a
384
+ * different one into it — `null` when nothing moves.
385
+ *
386
+ * `install` re-derives the path from ITS OWN environment, so a shell that exports
387
+ * `XDG_STATE_HOME` and a cron job that does not relocate the writer back and forth. The
388
+ * relocation itself is a separate question; a plan that changes where the telemetry lands
389
+ * without saying so is not, and the payloads left in the old directory are collected by
390
+ * nothing.
391
+ */
392
+ function movedFrom(p) {
393
+ const current = installedSnapshotsDir(p);
394
+ return current === null || current === p.snapshots ? null : current;
395
+ }
164
396
  /** The statusLine command as written, or `null` when there is none to read. */
165
397
  function commandOf(statusLine) {
166
398
  const command = statusLine?.command;
@@ -206,26 +438,83 @@ function commandTarget(command, home) {
206
438
  return firstWord(s);
207
439
  }
208
440
  /**
209
- * Only install-time code asks, never the render path but the path comes out of someone's
210
- * settings.json, so it may be a FIFO with no writer or a dead network mount. `O_NONBLOCK`,
211
- * because a tool that hangs before printing anything is indistinguishable from one that died.
441
+ * The first `size` bytes of a file, or `null` when there are none to be had.
442
+ *
443
+ * `O_NONBLOCK`, because the path can come out of someone's settings.json: a FIFO with no
444
+ * writer or a dead network mount would otherwise hang a tool before it printed anything.
212
445
  */
213
- function carriesWrapperMarker(file) {
446
+ function readHead(file, size) {
214
447
  let fd;
215
448
  try {
216
449
  fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
217
- const head = Buffer.alloc(512);
218
- const read = fs.readSync(fd, head, 0, head.length, 0);
219
- return head.subarray(0, read).toString('utf8').includes(WRAPPER_MARKER);
450
+ const head = Buffer.alloc(size);
451
+ const read = fs.readSync(fd, head, 0, size, 0);
452
+ return head.subarray(0, read).toString('utf8');
220
453
  }
221
454
  catch {
222
- return false;
455
+ return null;
223
456
  }
224
457
  finally {
225
458
  if (fd !== undefined)
226
459
  fs.closeSync(fd);
227
460
  }
228
461
  }
462
+ /** Only install-time code asks this, never the render path. */
463
+ function carriesWrapperMarker(file) {
464
+ return readHead(file, 512)?.includes(WRAPPER_MARKER) ?? false;
465
+ }
466
+ /**
467
+ * Is the file at the wrapper's path one of ours? Lets a caller tell "no install here" from
468
+ * "an install whose path we could not read" — two answers `installedSnapshotsDir` collapses
469
+ * into `null`, and only one of which is worth a word on stderr.
470
+ */
471
+ export const wrapperIsOurs = (p) => carriesWrapperMarker(p.wrapper);
472
+ /**
473
+ * Where the INSTALLED wrapper actually writes — read out of the wrapper itself.
474
+ *
475
+ * The wrapper carries an absolute path, frozen into the file at install time. `list` and
476
+ * `serve` used to RECOMPUTE the default instead, from their own `process.env` — so an
477
+ * `XDG_STATE_HOME` set in an interactive shell and absent from a LaunchAgent, a systemd user
478
+ * unit, cron or `sudo` without `-E` had the writer filing into A while the reader watched B.
479
+ *
480
+ * That split was silent by construction: a default directory that does not exist is the
481
+ * zero-config case, so `collect.ts` says nothing about it, and the only symptom was
482
+ * `statusline chained on 0/N sessions` — which the manual itself calls "a true statement
483
+ * about the wrong directory". A fleet monitor whose failure looks like a healthy empty fleet
484
+ * is the one failure it may not have.
485
+ *
486
+ * Reading our own generated file is not parsing an internal format: this is the file this
487
+ * module writes, and the marker is the same one `carriesWrapperMarker` trusts everywhere
488
+ * else. Nothing new is stored, and an install left by an older version is picked up as it
489
+ * stands. `null` means "no install here to ask" — the caller then computes the default.
490
+ */
491
+ export function installedSnapshotsDir(p) {
492
+ const head = readHead(p.wrapper, 8192);
493
+ if (head === null || !head.includes(WRAPPER_MARKER))
494
+ return null;
495
+ const line = /^TARMAC_DIR=(.*)$/m.exec(head);
496
+ return line === null ? null : shUnquote(line[1]);
497
+ }
498
+ /**
499
+ * The inverse of the single-quoting `renderWrapper` applies (`shQuote`, in `wrapper.ts`) —
500
+ * the exact one, never a guess.
501
+ *
502
+ * Double quotes are accepted too. Nothing here emits them, but a hand-edited wrapper, or one
503
+ * written by another generation of this file, exists; refusing a spelling every shell reads
504
+ * the same way would send the reader back to guessing from its own environment, which is the
505
+ * bug this function closes.
506
+ *
507
+ * An EMPTY value is not a path — it is a wrapper that writes nowhere. `args.ts` refuses
508
+ * `--snapshots-dir=` and `config.ts` refuses `"snapshotsDir": ""` for exactly that reason.
509
+ */
510
+ function shUnquote(text) {
511
+ const s = text.trim();
512
+ const quote = s.startsWith("'") ? "'" : s.startsWith('"') ? '"' : null;
513
+ if (quote === null || s.length < 2 || !s.endsWith(quote))
514
+ return null;
515
+ const body = quote === "'" ? s.slice(1, -1).split(`'\\''`).join("'") : s.slice(1, -1);
516
+ return body === '' ? null : body;
517
+ }
229
518
  /**
230
519
  * A backup we cannot trust is worse than none: it is the only record of the statusline we
231
520
  * wrapped. `previous: null` is legitimate ("there was no statusLine"), so the discriminant
@@ -246,11 +535,16 @@ export function install({ home }) {
246
535
  isSameCommand: (command, wrapper) => isWrapperCommand(command, root, wrapper),
247
536
  commandSpelling: quoteArg(p.wrapper),
248
537
  });
538
+ // Read here and nowhere later: two of its three proofs are things this function is about
539
+ // to write, so asking afterwards would always answer yes.
540
+ const wasInstalled = tarmacWasInstalledHere(p, alreadyInstalled);
249
541
  if (alreadyInstalled) {
250
542
  const backup = backupOrRefuse(p);
251
543
  fs.mkdirSync(p.snapshots, { recursive: true });
252
544
  writeWrapper(p, backup.previous?.command ?? null, root);
253
- return { alreadyInstalled: true, previous: backup.previous ?? null, ...p };
545
+ // After the wrapper, always: this is the update path, and until that write lands the
546
+ // frames are still filing into the directory being cleared.
547
+ return { alreadyInstalled: true, previous: backup.previous ?? null, legacy: purgeLegacySnapshots(p, wasInstalled), ...p };
254
548
  }
255
549
  // The other end of that order: everything from here CREATES, and the settings write is
256
550
  // the step that can still throw — a symlinked settings.json whose target lives in a
@@ -258,7 +552,7 @@ export function install({ home }) {
258
552
  // Left behind, the wrapper and the backup describe an install that never happened, and
259
553
  // that is precisely the state `uninstall` calls `foreign` and clears nothing of. So:
260
554
  // 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]);
555
+ const before = whatIsThere([p.dir, p.stateDir, p.snapshots, p.wrapper, p.backup]);
262
556
  try {
263
557
  fs.mkdirSync(p.dir, { recursive: true });
264
558
  fs.mkdirSync(p.snapshots, { recursive: true });
@@ -274,7 +568,7 @@ export function install({ home }) {
274
568
  unwind(p, before);
275
569
  throw failure;
276
570
  }
277
- return { alreadyInstalled: false, previous, ...p };
571
+ return { alreadyInstalled: false, previous, legacy: purgeLegacySnapshots(p, wasInstalled), ...p };
278
572
  }
279
573
  /**
280
574
  * What is on disk before we touch it: the paths that are already there, mapped to the bytes
@@ -345,7 +639,12 @@ function unwind(p, before) {
345
639
  drop(p.wrapper, (f) => fs.rmSync(f, { force: true }));
346
640
  // `rmdir`, not a recursive remove: a directory that has gained snapshots or a config since
347
641
  // we made it holds someone else's data now, and ENOTEMPTY is the answer we want.
642
+ //
643
+ // The state directory goes back too, deepest first — `mkdir -p` made both rungs. What is
644
+ // ABOVE it (`~/.local/state`) is XDG's, not ours: we may have created it on a home that
645
+ // had none, and unmaking it would be reaching past what this tool owns.
348
646
  drop(p.snapshots, (d) => fs.rmdirSync(d));
647
+ drop(p.stateDir, (d) => fs.rmdirSync(d));
349
648
  drop(p.dir, (d) => fs.rmdirSync(d));
350
649
  }
351
650
  /** Never let the wrapper chain to itself, whatever spelling the caller used. */
@@ -432,6 +731,9 @@ export function planUninstall({ home, realHome = os.homedir() }) {
432
731
  before: commandOf(current.statusLine),
433
732
  after,
434
733
  mode,
734
+ // 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,
435
737
  undo: undoCommand('install', root, isRealHome),
436
738
  };
437
739
  }
package/dist/render.js CHANGED
@@ -27,9 +27,34 @@ export function renderPlan(plan) {
27
27
  rows.push(['↳ which calls', `${plan.chained} (your display is unchanged)`]);
28
28
  if (plan.alreadyInstalled)
29
29
  rows.push(['note', 'already installed — the wrapper is regenerated, settings.json is left alone']);
30
+ // The snapshots directory is no longer under `.claude`, so it is no longer guessable
31
+ // from the path above it: naming it here is how a reader of `list`, `serve` or any other
32
+ // tool finds out where the payloads land.
33
+ rows.push(['snapshots', plan.snapshots]);
34
+ // A relocation is a change to where the telemetry lands, so it is never implied.
35
+ if (plan.movingFrom !== null)
36
+ rows.push(['↳ moving from', `${plan.movingFrom} (its payloads are left there, and nothing collects them)`]);
37
+ // This operation now DELETES files, inside a directory people commit. A plan that can
38
+ // disagree with what runs is worse than no plan — so it says how many, and where.
39
+ // A directory that is THERE but holds none of our payloads is the state the previous
40
+ // install left behind: nothing to announce, and nothing to ask anyone to commit.
41
+ const clearing = plan.legacy !== null && plan.legacy.payloads > 0;
42
+ if (clearing) {
43
+ rows.push([
44
+ '↳ clearing',
45
+ `${plan.legacy.payloads} runtime payload(s) under ${plan.legacy.dir} — each one is written again on the next frame`,
46
+ ]);
47
+ if (plan.legacy.kept > 0)
48
+ rows.push(['↳ keeping', `${plan.legacy.kept} file(s) nothing here wrote, so that directory stays`]);
49
+ }
50
+ if (plan.gitRepo !== null)
51
+ rows.push(['git', gitHint(plan.gitRepo, clearing)]);
30
52
  }
31
53
  else {
32
54
  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)`]);
33
58
  }
34
59
  rows.push(['undo', plan.undo]);
35
60
  const w = Math.max(...rows.map(([label]) => label.length));
@@ -37,6 +62,19 @@ export function renderPlan(plan) {
37
62
  rows.map(([label, value]) => ` ${label.padEnd(w)} ${value}\n`).join('') +
38
63
  '\n');
39
64
  }
65
+ /**
66
+ * The line #20 asked for, said once, to the only people it concerns: those whose `.claude`
67
+ * is a git repository.
68
+ *
69
+ * It has two jobs, and which one is live depends on whether the payloads are still there:
70
+ * an install that clears them produces a DELETION the user has to commit, and a `.gitignore`
71
+ * line keeps them from coming back if that directory is ever pointed at again. With nothing
72
+ * to clear, the only thing left to say is that this install adds nothing that churns.
73
+ */
74
+ const gitHint = (repo, hasLegacy) => `${repo.dir} is a git repository — ` +
75
+ (hasLegacy
76
+ ? `commit the removal above, and add \`${repo.ignore}\` to its .gitignore`
77
+ : 'nothing tarmac writes there changes at runtime; the snapshots live outside it');
40
78
  /** What each restore mode means, in the words the plan and the report both use. */
41
79
  export const restoreMeaning = (mode) => RESTORE_MEANING[mode];
42
80
  const RESTORE_MEANING = {
package/dist/wrapper.js CHANGED
@@ -54,6 +54,22 @@ export const PRUNE_EVERY_MIN = 60;
54
54
  * so nothing was reading that file as current anyway.
55
55
  */
56
56
  export const SNAPSHOT_TTL_MIN = 48 * 60;
57
+ /**
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.
62
+ *
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.
66
+ */
67
+ export const SNAPSHOT_GLOB = '????????-????-????-????-????????????.json';
68
+ /**
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.
71
+ */
72
+ export const SNAPSHOT_NAME = new RegExp(`^${SNAPSHOT_GLOB.replace(/\./g, '\\.').replace(/\?/g, '.')}$`);
57
73
  /** Single-quotes a string for POSIX sh. */
58
74
  function shQuote(s) {
59
75
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -171,13 +187,9 @@ if [ -d "$TARMAC_DIR" ]; then
171
187
  # keeps a directory or a symlink wearing a session id's name out of it.
172
188
  #
173
189
  # 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
190
+ # for the temp files: only what we wrote see SNAPSHOT_GLOB, which the legacy purge in
191
+ # \`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
181
193
  fi
182
194
  fi
183
195
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",
@@ -14,6 +14,14 @@
14
14
  ],
15
15
  "license": "MIT",
16
16
  "author": "Adrien Leboeuf",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/adrrr/tarmac.git"
20
+ },
21
+ "homepage": "https://github.com/adrrr/tarmac#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/adrrr/tarmac/issues"
24
+ },
17
25
  "type": "module",
18
26
  "engines": {
19
27
  "node": ">=20"