@adrrr/tarmac 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -191
- package/dist/cli.js +14 -13
- package/dist/install.js +116 -14
- package/dist/reap.js +2 -1
- package/dist/render.js +12 -0
- package/dist/server.js +70 -0
- package/dist/wrapper.js +88 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# tarmac
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@adrrr/tarmac)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

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