@adrrr/tarmac 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -47
- package/dist/fleet.js +11 -0
- package/dist/install.js +44 -12
- package/dist/map.js +110 -0
- package/dist/reap.js +9 -4
- package/dist/render.js +250 -21
- package/dist/server.js +12 -2
- package/dist/sessions.js +21 -1
- package/dist/snapshots.js +19 -2
- package/dist/wrapper.js +101 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# tarmac
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@adrrr/tarmac)
|
|
4
|
+
[](https://github.com/adrrr/tarmac/actions/workflows/ci.yml)
|
|
4
5
|

|
|
5
6
|

|
|
6
7
|

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