@adrrr/tarmac 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,189 @@ 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 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.
253
+ */
254
+ const isPayloadName = (name) => SNAPSHOT_NAME.test(name) || name.startsWith(TEMP_PREFIX) || name === PRUNE_MARKER;
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) => {
264
+ try {
265
+ return fs.lstatSync(file).isFile() ? 'file' : 'not-a-file';
266
+ }
267
+ catch {
268
+ return 'none';
269
+ }
270
+ };
271
+ /** The same question, for callers that only need the yes. */
272
+ const isPlainFile = (file) => markerState(file) === 'file';
273
+ /**
274
+ * Did an install of OURS already exist here, before this run wrote anything?
275
+ *
276
+ * `~/.claude/tarmac/snapshots` is a documented path — this project's own docs invite you to
277
+ * point a reader at one — and the "ours" set is a SHAPE: a UUID name, a `.tarmac-` prefix.
278
+ * Shape is not provenance. Without this, a FIRST install on a home tarmac had never touched
279
+ * deleted another writer's files, under a plan promising they would be "written again on the
280
+ * next frame" by a wrapper that had never written them. It is the same reasoning that spares
281
+ * a symlink two functions up, applied where it was missing.
282
+ *
283
+ * Three independent proofs, any one of which is enough: the statusLine already points at us
284
+ * (the update path), our marker is in the wrapper (an install whose settings.json was lost),
285
+ * or a usable backup is on disk (a wrapper someone deleted by hand). All three must be read
286
+ * BEFORE this run writes anything — by the time the purge runs it has written two of them.
287
+ */
288
+ export function tarmacWasInstalledHere(p, alreadyInstalled) {
289
+ return alreadyInstalled || carriesWrapperMarker(p.wrapper) || isUsableBackup(readBackup(p));
290
+ }
291
+ /** Read-only, for the plan: what an install would clear, before a byte is written. */
292
+ export function countLegacySnapshots(p, wasInstalled) {
293
+ if (!wasInstalled)
294
+ return null;
295
+ const found = readLegacyDir(p);
296
+ return found === null ? null : { dir: p.legacySnapshots, payloads: found.ours.length, kept: found.kept };
297
+ }
298
+ /**
299
+ * …and the deed. Best effort throughout: a payload we cannot remove is counted as KEPT, not
300
+ * as removed — which is both honest and exactly what decides whether the directory goes.
301
+ *
302
+ * Runs LAST in an install, after the wrapper has been rewritten: until those bytes land, the
303
+ * OLD wrapper is still the one Claude Code calls, and still dropping a file in here at every
304
+ * frame. That frame cannot be locked out — and its first act is `mkdir -p`, so it can put the
305
+ * whole directory back between the `rmdir` and this function returning. Hence the read-back:
306
+ * what is reported is what is ON DISK afterwards, never what was asked for. A user told to
307
+ * commit a removal that had already been undone is worse served than one told it did not take.
308
+ */
309
+ export function purgeLegacySnapshots(p, wasInstalled) {
310
+ if (!wasInstalled)
311
+ return null;
312
+ const found = readLegacyDir(p);
313
+ if (found === null)
314
+ return null;
315
+ let payloads = 0;
316
+ let kept = found.kept;
317
+ for (const name of found.ours) {
318
+ try {
319
+ fs.unlinkSync(path.join(p.legacySnapshots, name));
320
+ payloads += 1;
321
+ }
322
+ catch {
323
+ kept += 1;
324
+ }
325
+ }
326
+ if (kept === 0) {
327
+ // Twice at most. The first `rmdir` can lose a race with a frame's `mkdir -p`, and what
328
+ // that recreates is an EMPTY directory — which the second attempt takes. A second failure
329
+ // means the frame also wrote a payload into it: reported, not chased, because this is an
330
+ // install and not a daemon. Either way what is reported is read back from disk.
331
+ for (let pass = 0; pass < 2; pass++) {
332
+ try {
333
+ fs.rmdirSync(p.legacySnapshots);
334
+ }
335
+ catch {
336
+ // ENOTEMPTY from a frame that landed mid-sweep — the read-back below reports it.
337
+ }
338
+ const after = readLegacyDir(p);
339
+ if (after === null)
340
+ break; // gone, which is the whole point
341
+ kept = after.ours.length + after.kept;
342
+ }
343
+ }
344
+ return { dir: p.legacySnapshots, payloads, kept };
345
+ }
346
+ /**
347
+ * The version-controlled directory this install writes into, nearest first — `.claude` under
348
+ * its own repo, else a home that is one (`git init ~` is an ordinary dotfiles setup, and the
349
+ * hint was silent for it while the purge ran just the same). Nearest wins, so the advice is
350
+ * always about the repository the files are actually in.
351
+ *
352
+ * `.git` is a FILE in a worktree or a submodule — a `gitdir:` pointer — and the people who
353
+ * keep `.claude` in a dotfiles repo are exactly the people who use those, so this asks
354
+ * whether the name exists at all rather than what it is. A bare-repo setup (`yadm`, a
355
+ * `--git-dir` alias) has no `.git` anywhere and is not detected: stated, not papered over.
356
+ *
357
+ * Only ever used to SAY something. Nothing here reads, writes or runs git.
358
+ */
359
+ function gitRepoOf(p, home) {
360
+ const dir = [p.claude, home].find((d) => fs.existsSync(path.join(d, '.git')));
361
+ if (dir === undefined)
362
+ return null;
363
+ // Relative to the repository that will carry the `.gitignore`, with a trailing slash so it
364
+ // names a directory. A fixed `tarmac/snapshots/` was right for `.claude` and INERT for a
365
+ // home — `git check-ignore` says so, and a test now asks it rather than asking us.
366
+ return { dir, ignore: `${path.relative(dir, p.legacySnapshots)}/` };
367
+ }
138
368
  export function planInstall({ home, realHome = os.homedir() }) {
139
369
  const root = requireHome(home);
140
370
  const p = paths(root);
@@ -158,9 +388,27 @@ export function planInstall({ home, realHome = os.homedir() }) {
158
388
  after: alreadyInstalled ? before : quoteArg(p.wrapper),
159
389
  chained: alreadyInstalled ? (backupOrRefuse(p).previous?.command ?? null) : (previous?.command ?? null),
160
390
  alreadyInstalled,
391
+ snapshots: p.snapshots,
392
+ legacy: countLegacySnapshots(p, tarmacWasInstalledHere(p, alreadyInstalled)),
393
+ gitRepo: gitRepoOf(p, root),
394
+ movingFrom: movedFrom(p),
161
395
  undo: undoCommand('uninstall', root, isRealHome),
162
396
  };
163
397
  }
398
+ /**
399
+ * The directory the installed wrapper writes to today, when this install is about to freeze a
400
+ * different one into it — `null` when nothing moves.
401
+ *
402
+ * `install` re-derives the path from ITS OWN environment, so a shell that exports
403
+ * `XDG_STATE_HOME` and a cron job that does not relocate the writer back and forth. The
404
+ * relocation itself is a separate question; a plan that changes where the telemetry lands
405
+ * without saying so is not, and the payloads left in the old directory are collected by
406
+ * nothing.
407
+ */
408
+ function movedFrom(p) {
409
+ const current = installedSnapshotsDir(p);
410
+ return current === null || current === p.snapshots ? null : current;
411
+ }
164
412
  /** The statusLine command as written, or `null` when there is none to read. */
165
413
  function commandOf(statusLine) {
166
414
  const command = statusLine?.command;
@@ -206,26 +454,83 @@ function commandTarget(command, home) {
206
454
  return firstWord(s);
207
455
  }
208
456
  /**
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.
457
+ * The first `size` bytes of a file, or `null` when there are none to be had.
458
+ *
459
+ * `O_NONBLOCK`, because the path can come out of someone's settings.json: a FIFO with no
460
+ * writer or a dead network mount would otherwise hang a tool before it printed anything.
212
461
  */
213
- function carriesWrapperMarker(file) {
462
+ function readHead(file, size) {
214
463
  let fd;
215
464
  try {
216
465
  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);
466
+ const head = Buffer.alloc(size);
467
+ const read = fs.readSync(fd, head, 0, size, 0);
468
+ return head.subarray(0, read).toString('utf8');
220
469
  }
221
470
  catch {
222
- return false;
471
+ return null;
223
472
  }
224
473
  finally {
225
474
  if (fd !== undefined)
226
475
  fs.closeSync(fd);
227
476
  }
228
477
  }
478
+ /** Only install-time code asks this, never the render path. */
479
+ function carriesWrapperMarker(file) {
480
+ return readHead(file, 512)?.includes(WRAPPER_MARKER) ?? false;
481
+ }
482
+ /**
483
+ * Is the file at the wrapper's path one of ours? Lets a caller tell "no install here" from
484
+ * "an install whose path we could not read" — two answers `installedSnapshotsDir` collapses
485
+ * into `null`, and only one of which is worth a word on stderr.
486
+ */
487
+ export const wrapperIsOurs = (p) => carriesWrapperMarker(p.wrapper);
488
+ /**
489
+ * Where the INSTALLED wrapper actually writes — read out of the wrapper itself.
490
+ *
491
+ * The wrapper carries an absolute path, frozen into the file at install time. `list` and
492
+ * `serve` used to RECOMPUTE the default instead, from their own `process.env` — so an
493
+ * `XDG_STATE_HOME` set in an interactive shell and absent from a LaunchAgent, a systemd user
494
+ * unit, cron or `sudo` without `-E` had the writer filing into A while the reader watched B.
495
+ *
496
+ * That split was silent by construction: a default directory that does not exist is the
497
+ * zero-config case, so `collect.ts` says nothing about it, and the only symptom was
498
+ * `statusline chained on 0/N sessions` — which the manual itself calls "a true statement
499
+ * about the wrong directory". A fleet monitor whose failure looks like a healthy empty fleet
500
+ * is the one failure it may not have.
501
+ *
502
+ * Reading our own generated file is not parsing an internal format: this is the file this
503
+ * module writes, and the marker is the same one `carriesWrapperMarker` trusts everywhere
504
+ * else. Nothing new is stored, and an install left by an older version is picked up as it
505
+ * stands. `null` means "no install here to ask" — the caller then computes the default.
506
+ */
507
+ export function installedSnapshotsDir(p) {
508
+ const head = readHead(p.wrapper, 8192);
509
+ if (head === null || !head.includes(WRAPPER_MARKER))
510
+ return null;
511
+ const line = /^TARMAC_DIR=(.*)$/m.exec(head);
512
+ return line === null ? null : shUnquote(line[1]);
513
+ }
514
+ /**
515
+ * The inverse of the single-quoting `renderWrapper` applies (`shQuote`, in `wrapper.ts`) —
516
+ * the exact one, never a guess.
517
+ *
518
+ * Double quotes are accepted too. Nothing here emits them, but a hand-edited wrapper, or one
519
+ * written by another generation of this file, exists; refusing a spelling every shell reads
520
+ * the same way would send the reader back to guessing from its own environment, which is the
521
+ * bug this function closes.
522
+ *
523
+ * An EMPTY value is not a path — it is a wrapper that writes nowhere. `args.ts` refuses
524
+ * `--snapshots-dir=` and `config.ts` refuses `"snapshotsDir": ""` for exactly that reason.
525
+ */
526
+ function shUnquote(text) {
527
+ const s = text.trim();
528
+ const quote = s.startsWith("'") ? "'" : s.startsWith('"') ? '"' : null;
529
+ if (quote === null || s.length < 2 || !s.endsWith(quote))
530
+ return null;
531
+ const body = quote === "'" ? s.slice(1, -1).split(`'\\''`).join("'") : s.slice(1, -1);
532
+ return body === '' ? null : body;
533
+ }
229
534
  /**
230
535
  * A backup we cannot trust is worse than none: it is the only record of the statusline we
231
536
  * wrapped. `previous: null` is legitimate ("there was no statusLine"), so the discriminant
@@ -246,11 +551,16 @@ export function install({ home }) {
246
551
  isSameCommand: (command, wrapper) => isWrapperCommand(command, root, wrapper),
247
552
  commandSpelling: quoteArg(p.wrapper),
248
553
  });
554
+ // Read here and nowhere later: two of its three proofs are things this function is about
555
+ // to write, so asking afterwards would always answer yes.
556
+ const wasInstalled = tarmacWasInstalledHere(p, alreadyInstalled);
249
557
  if (alreadyInstalled) {
250
558
  const backup = backupOrRefuse(p);
251
559
  fs.mkdirSync(p.snapshots, { recursive: true });
252
560
  writeWrapper(p, backup.previous?.command ?? null, root);
253
- return { alreadyInstalled: true, previous: backup.previous ?? null, ...p };
561
+ // After the wrapper, always: this is the update path, and until that write lands the
562
+ // frames are still filing into the directory being cleared.
563
+ return { alreadyInstalled: true, previous: backup.previous ?? null, legacy: purgeLegacySnapshots(p, wasInstalled), ...p };
254
564
  }
255
565
  // The other end of that order: everything from here CREATES, and the settings write is
256
566
  // the step that can still throw — a symlinked settings.json whose target lives in a
@@ -258,7 +568,7 @@ export function install({ home }) {
258
568
  // Left behind, the wrapper and the backup describe an install that never happened, and
259
569
  // that is precisely the state `uninstall` calls `foreign` and clears nothing of. So:
260
570
  // 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]);
571
+ const before = whatIsThere([p.dir, p.stateDir, p.snapshots, p.wrapper, p.backup]);
262
572
  try {
263
573
  fs.mkdirSync(p.dir, { recursive: true });
264
574
  fs.mkdirSync(p.snapshots, { recursive: true });
@@ -274,7 +584,7 @@ export function install({ home }) {
274
584
  unwind(p, before);
275
585
  throw failure;
276
586
  }
277
- return { alreadyInstalled: false, previous, ...p };
587
+ return { alreadyInstalled: false, previous, legacy: purgeLegacySnapshots(p, wasInstalled), ...p };
278
588
  }
279
589
  /**
280
590
  * What is on disk before we touch it: the paths that are already there, mapped to the bytes
@@ -345,7 +655,12 @@ function unwind(p, before) {
345
655
  drop(p.wrapper, (f) => fs.rmSync(f, { force: true }));
346
656
  // `rmdir`, not a recursive remove: a directory that has gained snapshots or a config since
347
657
  // we made it holds someone else's data now, and ENOTEMPTY is the answer we want.
658
+ //
659
+ // The state directory goes back too, deepest first — `mkdir -p` made both rungs. What is
660
+ // ABOVE it (`~/.local/state`) is XDG's, not ours: we may have created it on a home that
661
+ // had none, and unmaking it would be reaching past what this tool owns.
348
662
  drop(p.snapshots, (d) => fs.rmdirSync(d));
663
+ drop(p.stateDir, (d) => fs.rmdirSync(d));
349
664
  drop(p.dir, (d) => fs.rmdirSync(d));
350
665
  }
351
666
  /** Never let the wrapper chain to itself, whatever spelling the caller used. */
@@ -422,6 +737,7 @@ export function planUninstall({ home, realHome = os.homedir() }) {
422
737
  after = restored ? commandOf(settings.statusLine) : commandOf(current.statusLine);
423
738
  }
424
739
  const isRealHome = sameFile(root, realHome);
740
+ const snapshots = installedSnapshotsDir(p);
425
741
  return {
426
742
  action: 'uninstall',
427
743
  home: root,
@@ -432,6 +748,11 @@ export function planUninstall({ home, realHome = os.homedir() }) {
432
748
  before: commandOf(current.statusLine),
433
749
  after,
434
750
  mode,
751
+ // Where they REALLY are: `uninstall` leaves them behind, so the path it prints has to be
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)),
435
756
  undo: undoCommand('install', root, isRealHome),
436
757
  };
437
758
  }
@@ -441,10 +762,20 @@ function installedBackupOrRefuse(p) {
441
762
  throw new Error(`no tarmac install found under ${p.dir}`);
442
763
  return backup;
443
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
+ }
444
772
  export function uninstall({ home }) {
445
773
  const root = requireHome(home);
446
774
  const p = paths(root);
447
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);
448
779
  const currentText = fs.existsSync(p.settings) ? fs.readFileSync(p.settings, 'utf8') : null;
449
780
  let mode;
450
781
  if (currentText === backup.installedText) {
@@ -472,7 +803,10 @@ export function uninstall({ home }) {
472
803
  writeAtomic(p.settings, JSON.stringify(settings, null, 2) + '\n');
473
804
  mode = 'surgical';
474
805
  }
475
- // 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.
476
810
  fs.rmSync(p.wrapper, { force: true });
477
811
  fs.rmSync(p.backup, { force: true });
478
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
  /**