@adrrr/tarmac 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -47
- package/dist/cli.js +20 -5
- package/dist/collect.js +17 -9
- package/dist/fleet.js +11 -0
- package/dist/install.js +349 -15
- package/dist/map.js +110 -0
- package/dist/reap.js +9 -4
- package/dist/render.js +285 -18
- package/dist/server.js +12 -2
- package/dist/sessions.js +21 -1
- package/dist/snapshots.js +19 -2
- package/dist/wrapper.js +108 -14
- package/package.json +9 -1
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(
|
|
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
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* because
|
|
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
|
|
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(
|
|
218
|
-
const read = fs.readSync(fd, head, 0,
|
|
219
|
-
return head.subarray(0, read).toString('utf8')
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
23
|
-
//
|
|
24
|
-
|
|
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
|
/**
|