@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 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
+ }
@@ -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
+ }