@adrrr/tarmac 0.1.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/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/args.js +93 -0
- package/dist/cli.js +166 -0
- package/dist/collect.js +26 -0
- package/dist/config.js +184 -0
- package/dist/discover.js +26 -0
- package/dist/fleet.js +85 -0
- package/dist/install.js +387 -0
- package/dist/prompt.js +29 -0
- package/dist/reap.js +64 -0
- package/dist/render.js +524 -0
- package/dist/schema.js +87 -0
- package/dist/server.js +80 -0
- package/dist/sessions.js +51 -0
- package/dist/settings.js +65 -0
- package/dist/shell.js +73 -0
- package/dist/snapshots.js +137 -0
- package/dist/watch.js +49 -0
- package/dist/wrapper.js +121 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adrien Leboeuf
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# tarmac
|
|
2
|
+
|
|
3
|
+
**Fleet observability for Claude Code.** One table for every session you have running —
|
|
4
|
+
busy or idle, how full its context is, which model, what it has cost so far.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
$ npx @adrrr/tarmac list
|
|
8
|
+
|
|
9
|
+
PROJECT STATE CTX AS OF MODEL EFFORT COST UP
|
|
10
|
+
apollo busy 28% 7m Fable 5 max $53.98 8h
|
|
11
|
+
mercury-dashboard busy 27% 22m ! Fable 5 max $70.62 8h
|
|
12
|
+
gemini idle 12% 3h ! Opus 5 high $3.14 8h
|
|
13
|
+
atlas idle — fresh 8h ! Opus 5 high $0.00 8h
|
|
14
|
+
|
|
15
|
+
! 3 reading(s) marked "!" are older than the freshness threshold
|
|
16
|
+
|
|
17
|
+
4 sessions · 2 busy · $127.74
|
|
18
|
+
```
|
|
19
|
+
|
|
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
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx @adrrr/tarmac install # chain the status line under ~/.claude, after showing you what changes
|
|
59
|
+
npx @adrrr/tarmac list # one-shot table
|
|
60
|
+
npx @adrrr/tarmac list --watch # the same table, redrawn every 5s until ^C
|
|
61
|
+
npx @adrrr/tarmac serve # dashboard on http://127.0.0.1:4477
|
|
62
|
+
npx @adrrr/tarmac uninstall # put your status line back, exactly
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Node ≥ 20. **Zero runtime dependencies** — no framework, no bundler, nothing to audit.
|
|
66
|
+
|
|
67
|
+
`install` and `uninstall` change a file your terminal reads on every frame, so neither runs
|
|
68
|
+
on your say-so alone. Both print the plan first — the settings file, what `statusLine` says
|
|
69
|
+
now, what it will say, the command that is being wrapped, and the exact command that undoes
|
|
70
|
+
it — and then wait for you to **type the verb**:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
tarmac install — your home
|
|
74
|
+
|
|
75
|
+
file /Users/you/.claude/settings.json
|
|
76
|
+
statusLine now ~/bin/my-line.sh
|
|
77
|
+
statusLine next /Users/you/.claude/tarmac/statusline.sh
|
|
78
|
+
↳ which calls ~/bin/my-line.sh (your display is unchanged)
|
|
79
|
+
undo tarmac uninstall
|
|
80
|
+
|
|
81
|
+
Type "install" to proceed, anything else to abort:
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`y` is not an answer, and neither is silence: with stdin not a terminal — a pipe, a CI job —
|
|
85
|
+
tarmac refuses rather than read consent from an unanswerable prompt. Scripts pass `--yes`,
|
|
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:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
ls -l DIR/.claude/tarmac/snapshots/.*.tmp # look first
|
|
113
|
+
rm DIR/.claude/tarmac/snapshots/.*.tmp # then remove what you recognise
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Commands
|
|
117
|
+
|
|
118
|
+
| Command | What it does | Options |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `tarmac list` | one-shot fleet table | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
|
|
121
|
+
| `tarmac serve` | local dashboard, `GET /` for the page, `GET /live` for the fragment it refreshes, `GET /api/fleet` for JSON | `--home`, `--port`, `--stale-after`, `--snapshots-dir`, `--claude-bin` |
|
|
122
|
+
| `tarmac install` | chain the status line under `<home>/.claude/settings.json`, after confirmation | `--home`, `--yes` |
|
|
123
|
+
| `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
|
|
124
|
+
|
|
125
|
+
`--help` works everywhere. An option handed to a command that does not read it is an
|
|
126
|
+
**error**, not something quietly ignored — `tarmac serve --json` would otherwise parse
|
|
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.
|
|
166
|
+
|
|
167
|
+
## Configuration
|
|
168
|
+
|
|
169
|
+
Three of tarmac's numbers are opinions, not truths, so all three are yours to set. Nothing
|
|
170
|
+
else is configurable, and every one of them keeps working with no configuration at all.
|
|
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` |
|
|
177
|
+
|
|
178
|
+
**Flag beats environment beats config file beats default**, settled per setting — a port
|
|
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.
|
|
210
|
+
|
|
211
|
+
Two edges worth knowing:
|
|
212
|
+
|
|
213
|
+
- **No config file is not an error** — it is the zero-config contract. `install` and
|
|
214
|
+
`uninstall` never read the file at all, so a typo in it can never be what stands between
|
|
215
|
+
you and putting your status line back.
|
|
216
|
+
- The snapshots directory is a **read-side** setting, exactly like the flag it mirrors.
|
|
217
|
+
The wrapper writes where `install` put it, under `<home>/.claude/tarmac/snapshots`. Point
|
|
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.
|
|
221
|
+
|
|
222
|
+
The dashboard binds to loopback and refuses any request whose `Host` is not loopback —
|
|
223
|
+
a DNS-rebinding page in your own browser would otherwise read your cwd paths and costs.
|
|
224
|
+
That guard runs before routing, so it covers `/live` and `/api/fleet` as well as the page.
|
|
225
|
+
|
|
226
|
+
## What V1 does not do
|
|
227
|
+
|
|
228
|
+
- **No "waiting for you" signal.** The obvious missing column — which session is blocked on
|
|
229
|
+
a human — is deliberately absent: the status line payload carries nothing that means it,
|
|
230
|
+
and `agents --json` reports `idle` for a session waiting on you and for one that finished.
|
|
231
|
+
Inferring it would mean reading a transcript, which is the one thing this tool will not do.
|
|
232
|
+
- **No history.** Each run is a snapshot in time; context and cost curves come later.
|
|
233
|
+
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
234
|
+
- **No remote fleets.** It watches the machine it runs on.
|
|
235
|
+
|
|
236
|
+
## Development
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
npm test # typecheck (src + test + scripts), then run the suite
|
|
240
|
+
npm run build # flat JavaScript into dist/
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
CI runs the suite on Node 22 and 24, on Linux and macOS, with `TARMAC_REQUIRE_DASH=1` so a
|
|
244
|
+
machine without dash cannot report a green build it did not earn; a separate job builds
|
|
245
|
+
`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: see
|
|
247
|
+
[PUBLISHING.md](PUBLISHING.md).
|
|
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.
|
|
266
|
+
|
|
267
|
+
The suite runs the TypeScript sources directly through Node's type stripping, so it needs
|
|
268
|
+
Node ≥ 22.18 to *develop*; what ships in `dist/` is plain ES2022 and runs on Node ≥ 20.
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
MIT
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Tiny argv parser. `node:util.parseArgs` would do, but a hand-rolled one keeps the error
|
|
2
|
+
// messages actionable and the surface exactly as wide as the commands we support.
|
|
3
|
+
//
|
|
4
|
+
// Unknown options and unknown commands are ERRORS. A typo silently ignored is how someone
|
|
5
|
+
// ends up believing they pointed tarmac at a directory it never read.
|
|
6
|
+
import { parsePort } from './config.js';
|
|
7
|
+
const COMMANDS = new Set(['list', 'serve', 'install', 'uninstall', 'help']);
|
|
8
|
+
// `| undefined` is load-bearing, not decoration: without it the compiler types the lookup
|
|
9
|
+
// below as always-present and the `if (!key) throw` guard reads as dead code — which is how
|
|
10
|
+
// a future cleanup deletes the one thing standing between a typo and a silently ignored flag.
|
|
11
|
+
const OPTIONS = {
|
|
12
|
+
'--port': 'port',
|
|
13
|
+
'--stale-after': 'staleAfter',
|
|
14
|
+
'--snapshots-dir': 'snapshotsDir',
|
|
15
|
+
'--home': 'home',
|
|
16
|
+
'--claude-bin': 'claudeBin',
|
|
17
|
+
'--json': 'json',
|
|
18
|
+
'--watch': 'watch',
|
|
19
|
+
'--yes': 'yes',
|
|
20
|
+
'--help': 'help',
|
|
21
|
+
};
|
|
22
|
+
const FLAGS = new Set(['json', 'watch', 'yes', 'help']);
|
|
23
|
+
/**
|
|
24
|
+
* Which options each command really reads. The doc-comment above refuses a flag nobody
|
|
25
|
+
* implements; this refuses a flag THIS command does not implement, which is the same defect
|
|
26
|
+
* one level down — `tarmac serve --json` parsed cleanly and changed nothing, and from the
|
|
27
|
+
* outside that is indistinguishable from a server that decided to answer HTML anyway.
|
|
28
|
+
*
|
|
29
|
+
* `help` is in every set on purpose: `--help` is answerable whatever else was typed.
|
|
30
|
+
*/
|
|
31
|
+
const ACCEPTS = {
|
|
32
|
+
list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help']),
|
|
33
|
+
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'help']),
|
|
34
|
+
install: new Set(['home', 'yes', 'help']),
|
|
35
|
+
uninstall: new Set(['home', 'yes', 'help']),
|
|
36
|
+
help: new Set(['help']),
|
|
37
|
+
};
|
|
38
|
+
/** The commands a misplaced flag would have been right on — an error that points somewhere. */
|
|
39
|
+
function ownersOf(key) {
|
|
40
|
+
return Object.keys(ACCEPTS).filter((c) => c !== 'help' && ACCEPTS[c].has(key)).join(', ');
|
|
41
|
+
}
|
|
42
|
+
/** Every flag this parser knows, so a documentation check can enumerate rather than guess. */
|
|
43
|
+
export const OPTION_FLAGS = Object.keys(OPTIONS);
|
|
44
|
+
/**
|
|
45
|
+
* Does `command` read `flag`? Exported for the test that holds `--help` to this matrix:
|
|
46
|
+
* asking the parser is the only way to check that does not go through the wording of an
|
|
47
|
+
* error message, which a reword would silently turn into a test that greens on everything.
|
|
48
|
+
*/
|
|
49
|
+
export function accepts(command, flag) {
|
|
50
|
+
const key = OPTIONS[flag];
|
|
51
|
+
return key !== undefined && ACCEPTS[command].has(key);
|
|
52
|
+
}
|
|
53
|
+
export function parseArgs(argv) {
|
|
54
|
+
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false };
|
|
55
|
+
let i = 0;
|
|
56
|
+
if (argv[0] && !argv[0].startsWith('-')) {
|
|
57
|
+
if (!COMMANDS.has(argv[0]))
|
|
58
|
+
throw new Error(`unknown command: ${argv[0]}`);
|
|
59
|
+
out.command = argv[0];
|
|
60
|
+
i = 1;
|
|
61
|
+
}
|
|
62
|
+
for (; i < argv.length; i++) {
|
|
63
|
+
const [flag, inline] = splitInline(argv[i]);
|
|
64
|
+
const key = OPTIONS[flag];
|
|
65
|
+
if (!key)
|
|
66
|
+
throw new Error(`unknown option: ${flag}`);
|
|
67
|
+
if (!ACCEPTS[out.command].has(key))
|
|
68
|
+
throw new Error(`${flag} is not an option of \`tarmac ${out.command}\` — it belongs to: ${ownersOf(key)}`);
|
|
69
|
+
if (FLAGS.has(key)) {
|
|
70
|
+
out[key] = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const value = inline ?? argv[++i];
|
|
74
|
+
// An EMPTY value is no value: `--snapshots-dir=` used to sail through as an empty path,
|
|
75
|
+
// and the fleet then read the process's cwd and reported a calm "nothing is chained".
|
|
76
|
+
// The config file has always refused an empty path; the flags refuse it in the same words.
|
|
77
|
+
if (value === undefined || value.trim() === '')
|
|
78
|
+
throw new Error(`${flag} needs a value`);
|
|
79
|
+
if (key === 'port') {
|
|
80
|
+
// Same validator the environment and the config file go through, so a port is refused
|
|
81
|
+
// in the same words wherever it was set.
|
|
82
|
+
out.port = parsePort(value, '--port');
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
out[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function splitInline(arg) {
|
|
91
|
+
const eq = arg.indexOf('=');
|
|
92
|
+
return eq === -1 ? [arg, null] : [arg.slice(0, eq), arg.slice(eq + 1)];
|
|
93
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tarmac — fleet observability for Claude Code.
|
|
3
|
+
//
|
|
4
|
+
// Two contractual surfaces, zero internal formats:
|
|
5
|
+
// `claude agents --json` → which sessions exist, busy or idle
|
|
6
|
+
// statusLine payload → context, model, effort, cost (via a chained wrapper)
|
|
7
|
+
//
|
|
8
|
+
// `install` / `uninstall` default to the home this process runs under, print what they are
|
|
9
|
+
// about to change, and proceed only on the typed verb (or `--yes`, in writing, for scripts).
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
12
|
+
import { parseArgs } from './args.js';
|
|
13
|
+
import { collectFleet } from './collect.js';
|
|
14
|
+
import { readConfigFile, resolveConfig, SOURCE_PHRASE } from './config.js';
|
|
15
|
+
import { createFleetServer } from './server.js';
|
|
16
|
+
import { install, uninstall, paths, planInstall, planUninstall } from './install.js';
|
|
17
|
+
import { confirmTyped } from './prompt.js';
|
|
18
|
+
import { reapOrphanedTemps } from './reap.js';
|
|
19
|
+
import { renderPlan, renderSettings, renderTable, restoreMeaning } from './render.js';
|
|
20
|
+
import { runWatch } from './watch.js';
|
|
21
|
+
const USAGE = `tarmac — fleet observability for Claude Code
|
|
22
|
+
|
|
23
|
+
tarmac list [--home DIR] [--stale-after D] [--snapshots-dir DIR]
|
|
24
|
+
[--claude-bin PATH] [--json] [--watch]
|
|
25
|
+
one-shot fleet table — with --watch, redrawn every 5s until ^C
|
|
26
|
+
tarmac serve [--home DIR] [--port N] [--stale-after D] [--snapshots-dir DIR]
|
|
27
|
+
[--claude-bin PATH]
|
|
28
|
+
local dashboard
|
|
29
|
+
tarmac install [--home DIR] [--yes]
|
|
30
|
+
chain the statusline
|
|
31
|
+
tarmac uninstall [--home DIR] [--yes]
|
|
32
|
+
restore the statusline exactly
|
|
33
|
+
|
|
34
|
+
--watch redraw the table every 5s until ^C, dating every reading
|
|
35
|
+
--home whose .claude to read or change (default: this home)
|
|
36
|
+
--yes skip the typed confirmation — required when stdin is not a terminal
|
|
37
|
+
--stale-after how old a reading may be before it is marked "!" — 90s, 15m, 2h
|
|
38
|
+
(default: 10m)
|
|
39
|
+
--port the dashboard's port (default: 4477)
|
|
40
|
+
--snapshots-dir where the chained statusline drops its payloads
|
|
41
|
+
(default: <home>/.claude/tarmac/snapshots)
|
|
42
|
+
--claude-bin path to the claude CLI (default: claude)
|
|
43
|
+
|
|
44
|
+
Those three settings can also be set, in decreasing order of precedence, by the
|
|
45
|
+
environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR) and by
|
|
46
|
+
<home>/.claude/tarmac/config.json ({"staleAfterMs": …, "port": …, "snapshotsDir": …}).
|
|
47
|
+
\`serve\` prints which one won.
|
|
48
|
+
`;
|
|
49
|
+
try {
|
|
50
|
+
// Parsing is inside the try so that a refusal — a typo'd flag, a duration nobody can read,
|
|
51
|
+
// a config file with a key that does not exist — reaches the user as one line naming the
|
|
52
|
+
// knob to turn, and never as a stack trace.
|
|
53
|
+
const args = parseArgs(process.argv.slice(2));
|
|
54
|
+
if (args.help || args.command === 'help') {
|
|
55
|
+
process.stdout.write(USAGE);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
if (args.command === 'install' || args.command === 'uninstall') {
|
|
59
|
+
const home = args.home ?? os.homedir();
|
|
60
|
+
// The plan is computed first and printed whole: every refusal this operation has in it
|
|
61
|
+
// fires here, so the prompt never appears for something that was going to fail anyway.
|
|
62
|
+
const plan = args.command === 'install' ? planInstall({ home }) : planUninstall({ home });
|
|
63
|
+
process.stdout.write(renderPlan(plan));
|
|
64
|
+
const confirmed = await confirmTyped({
|
|
65
|
+
word: args.command,
|
|
66
|
+
input: process.stdin,
|
|
67
|
+
output: process.stdout,
|
|
68
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
69
|
+
yes: args.yes,
|
|
70
|
+
});
|
|
71
|
+
// One exit path for everything that refuses, so nothing can leave a half-written stream
|
|
72
|
+
// behind on the way out.
|
|
73
|
+
if (!confirmed)
|
|
74
|
+
throw new Error('not confirmed — nothing was changed');
|
|
75
|
+
if (plan.action === 'install') {
|
|
76
|
+
const res = install({ home });
|
|
77
|
+
console.log(res.alreadyInstalled
|
|
78
|
+
? `install: already installed — wrapper regenerated, settings.json left alone`
|
|
79
|
+
: `install: statusLine wrapped — undo with \`${plan.undo}\``);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const { mode } = uninstall({ home });
|
|
83
|
+
console.log(`uninstall: ${mode} — ${restoreMeaning(mode)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Only the reading commands resolve settings, and only they read the config file: a
|
|
88
|
+
// typo in it must not be what stands between a user and `tarmac uninstall`.
|
|
89
|
+
const p = paths(args.home ?? os.homedir());
|
|
90
|
+
const config = resolveConfig({
|
|
91
|
+
flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir },
|
|
92
|
+
env: process.env,
|
|
93
|
+
file: readConfigFile(p.config),
|
|
94
|
+
defaultSnapshotsDir: p.snapshots,
|
|
95
|
+
});
|
|
96
|
+
const snapshotsDir = config.snapshotsDir.value;
|
|
97
|
+
const staleAfterMs = config.staleAfterMs.value;
|
|
98
|
+
if (args.command === 'serve') {
|
|
99
|
+
// Unattended for hours, so it opens by saying what it decided and on whose authority.
|
|
100
|
+
process.stdout.write(renderSettings(config, p.config));
|
|
101
|
+
// The one place tarmac deletes anything: temp files its own wrapper left behind when a
|
|
102
|
+
// terminal died mid-write. Best effort, and it says what it did rather than doing it
|
|
103
|
+
// quietly — this is the user's directory.
|
|
104
|
+
const { reaped, failed } = reapOrphanedTemps(snapshotsDir);
|
|
105
|
+
if (reaped > 0)
|
|
106
|
+
console.log(`tarmac: reaped ${reaped} orphaned snapshot temp file(s)`);
|
|
107
|
+
if (failed > 0)
|
|
108
|
+
console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
|
|
109
|
+
const server = createFleetServer({
|
|
110
|
+
collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source }),
|
|
111
|
+
});
|
|
112
|
+
// `listen` fails asynchronously, long after this try block has been left behind, so
|
|
113
|
+
// its refusal needs its own way out: a port already taken is the ordinary failure of a
|
|
114
|
+
// port pinned in a config file, and it has to read like every other refusal here.
|
|
115
|
+
server.on('error', (e) => {
|
|
116
|
+
const why = e.code === 'EADDRINUSE' ? 'already in use' : e.message;
|
|
117
|
+
console.error(`tarmac: cannot listen on port ${config.port.value} (${SOURCE_PHRASE[config.port.source]}) — ${why}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
|
120
|
+
server.listen(config.port.value, '127.0.0.1', () => {
|
|
121
|
+
console.log(`tarmac serving http://127.0.0.1:${server.address().port}`);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source });
|
|
126
|
+
if (args.watch) {
|
|
127
|
+
// One redraws a screen, the other is meant to be piped once. Silently letting one win
|
|
128
|
+
// is how someone ends up parsing a frame of terminal art.
|
|
129
|
+
if (args.json)
|
|
130
|
+
throw new Error('--watch and --json cannot be combined — a watch redraws a screen, --json prints once');
|
|
131
|
+
const stop = new AbortController();
|
|
132
|
+
// Ctrl-C leaves through the loop rather than through the window: the frame in flight
|
|
133
|
+
// finishes, and the process exits 0 like every other reading command.
|
|
134
|
+
//
|
|
135
|
+
// The second one is not politeness, it is the promise every frame prints. Registering
|
|
136
|
+
// a handler replaces Node's default terminate, and the loop only checks the flag
|
|
137
|
+
// between awaits — so a Ctrl-C during a slow `claude agents --json` was swallowed, and
|
|
138
|
+
// so was the next one, for as long as the read took. A watch that prints "^C to quit"
|
|
139
|
+
// has to be leavable with ^C.
|
|
140
|
+
process.on('SIGINT', () => {
|
|
141
|
+
if (stop.signal.aborted)
|
|
142
|
+
process.exit(130);
|
|
143
|
+
stop.abort();
|
|
144
|
+
});
|
|
145
|
+
await runWatch({
|
|
146
|
+
collect,
|
|
147
|
+
write: (frame) => process.stdout.write(frame),
|
|
148
|
+
// The abort lands DURING the wait, which is the whole point of the wait — so it
|
|
149
|
+
// resolves rather than rejecting, and the loop's own check is what ends it.
|
|
150
|
+
sleep: (ms) => sleep(ms, undefined, { signal: stop.signal }).then(() => { }, () => { }),
|
|
151
|
+
now: () => Date.now(),
|
|
152
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
153
|
+
signal: stop.signal,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const fleet = await collect();
|
|
158
|
+
process.stdout.write(args.json ? JSON.stringify(fleet, null, 2) + '\n' : renderTable(fleet));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
console.error(`tarmac: ${e.message}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
package/dist/collect.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// P3 — one call, both contractual sources, one fleet view. Strictly read-only.
|
|
2
|
+
import { SOURCE_PHRASE } from './config.js';
|
|
3
|
+
import { discoverSessions } from './discover.js';
|
|
4
|
+
import { readSnapshots } from './snapshots.js';
|
|
5
|
+
import { buildFleet } from './fleet.js';
|
|
6
|
+
export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', }) {
|
|
7
|
+
const { sessions, health: discovery } = await discoverSessions({ claudeBin });
|
|
8
|
+
const { snapshots, dirError, unreadable, duplicates, dirMissing } = readSnapshots(snapshotsDir, { now });
|
|
9
|
+
const fleet = buildFleet({ sessions, snapshots, now, discovery, staleAfterMs });
|
|
10
|
+
// Both blind spots travel with the data: a directory we could not read and files we
|
|
11
|
+
// could not parse are OUR failures to report, not silence to render as "all clear".
|
|
12
|
+
//
|
|
13
|
+
// An absent directory is judged HERE, because this is the layer that knows who chose it:
|
|
14
|
+
// the default may simply not exist yet (nothing has been chained), but a path someone
|
|
15
|
+
// typed — flag, environment, or a config file edited months ago — that is not there is a
|
|
16
|
+
// setting pointing at nothing. Rendered as "not chained yet" it sends the user to run
|
|
17
|
+
// `tarmac install`, which cannot fix it: install writes where install writes.
|
|
18
|
+
fleet.health.snapshotsError =
|
|
19
|
+
dirMissing && snapshotsDirSource !== 'default'
|
|
20
|
+
? `ENOENT: ${snapshotsDir} does not exist — set by ${SOURCE_PHRASE[snapshotsDirSource]}`
|
|
21
|
+
: dirError;
|
|
22
|
+
fleet.health.snapshotsUnreadable = unreadable;
|
|
23
|
+
fleet.health.snapshotsDuplicates = duplicates;
|
|
24
|
+
fleet.health.snapshotsDir = snapshotsDir;
|
|
25
|
+
return fleet;
|
|
26
|
+
}
|