@adrrr/tarmac 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,14 +6,14 @@
6
6
  ![runtime dependencies](https://img.shields.io/badge/runtime_dependencies-0-brightgreen)
7
7
  ![license](https://img.shields.io/badge/license-MIT-blue)
8
8
 
9
- **Fleet observability for Claude Code.** One table for every session you have running
9
+ **Fleet observability for Claude Code.** One table for every session you have running:
10
10
  busy or idle, how full its context is, which model, what it has cost so far. It reads
11
11
  documented surfaces only, never an internal format.
12
12
 
13
13
  <picture>
14
14
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/replay-dark.gif">
15
15
  <img src="docs/media/replay-light.gif" width="1100"
16
- alt="The tarmac map, live and then replayed. It opens on the live fleet, whose nodes are grouped into a frame per working directory, each labelled with its project. As the scrubber is dragged the frames give way to the flat record sessions drawn as dials, each arc a context window, background agents as strips of text with the account's five-hour and seven-day gauges above them. Sessions appear and disappear, the arcs fill and reset, the five-hour window fills and rolls over, and a banner names the minute being replayed until Back to live is pressed and the frames come back.">
16
+ alt="The tarmac map, live and then replayed. It opens on the live fleet, whose nodes are grouped into a frame per working directory, each labelled with its project. As the scrubber is dragged the frames give way to the flat record: sessions drawn as dials, each arc a context window, background agents as strips of text, with the account's five-hour and seven-day gauges above them. Sessions appear and disappear, the arcs fill and reset, the five-hour window fills and rolls over, and a banner names the minute being replayed until Back to live is pressed and the frames come back.">
17
17
  </picture>
18
18
 
19
19
  [Quickstart](#quickstart) · [The map](#the-map) · [Install](#install) ·
@@ -42,127 +42,139 @@ atlas idle — fresh 8h ! Opus 5 high $0.00 8h
42
42
  ! 3 reading(s) marked "!" are older than 10m (--stale-after)
43
43
 
44
44
  4 sessions · 2 busy · $109.35
45
+ account 5h 17% resets in 2h 14m · 7d 42% resets in 3d 11h · as of 7m
45
46
  ```
46
47
 
47
- That is the table with the status line chained. Without it nothing installed at all the
48
- same command still lists every session, its state and its uptime, straight from
49
- `claude agents --json`; the context column reads `— absent`, model, effort and cost fall to
50
- `—`, and the line under the table counts how many sessions are covered.
48
+ That is the table with the status line chained. The last line belongs to the account, not to
49
+ any one session. Every row above spends from the same five-hour and seven-day
50
+ [window](docs/MANUAL.md#the-accounts-two-windows), so it is printed once. It is dated like
51
+ every other reading here, and it reads `— no reading` rather than `0%` when no snapshot
52
+ carried one.
51
53
 
52
- Node 20. **Zero runtime dependencies** no framework, no bundler, nothing to audit.
53
- `--help` works everywhere, and an option handed to a command that does not read it is an
54
- **error**, not something quietly ignored. Every command, flag and route:
54
+ With nothing installed, the same command still lists every session, its state and its uptime,
55
+ straight from `claude agents --json`. The context column reads `— absent`. Model, effort and
56
+ cost fall to `—`. The line under the table counts how many sessions are covered.
57
+
58
+ Node ≥ 20. Zero runtime dependencies: no framework, no bundler, nothing to audit.
59
+ `--help` and `--version` (`-v`) work everywhere. An option handed to a command that does not
60
+ read it is an error, never something quietly ignored. Every command, flag and route is in
55
61
  [the manual](docs/MANUAL.md#commands-and-options).
56
62
 
57
63
  ## The map
58
64
 
59
- `tarmac serve` puts the same fleet in the browser every session a row, ages that keep
60
- climbing, and a banner the moment a refresh fails instead of a table quietly going stale. It
61
- binds to loopback and refuses any request whose `Host` is not loopback, or that a browser marks
62
- as coming from another origin: your cwd paths and costs never leave the machine. A client that
63
- sends no such mark curl, a script is left alone. The listening rules:
64
- [the manual](docs/MANUAL.md#what-serve-listens-on).
65
+ `tarmac serve` puts the same fleet in the browser. Every session is a row, the ages keep
66
+ climbing, and a banner goes up the moment a refresh fails, so the table never goes stale in
67
+ silence.
68
+
69
+ It binds to loopback. It refuses any request whose `Host` is not loopback, and any request a
70
+ browser marks as coming from another origin, so your cwd paths and costs never leave the
71
+ machine. A client that sends no such mark, curl or a script, is left alone. A reverse proxy
72
+ presents a `Host` of its own, so `--trust-host <name>` names the one to let through;
73
+ [the manual](docs/MANUAL.md#putting-it-behind-a-reverse-proxy) says who that lets in. The
74
+ listening rules are [there too](docs/MANUAL.md#what-serve-listens-on).
65
75
 
66
76
  <picture>
67
77
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
68
78
  <img src="docs/media/map-light.png" width="1100"
69
- alt="The tarmac map, live. Four framed groups, one per working directory and labelled with its project: beacon, holding a session halted on a permission prompt; harbor, holding a session busy with its context arc at 90% and a background agent docked under the cards of that frame as a strip named after its prompt; quay, reporting a status tarmac does not know; and atlas, idle at 36%. A frame says only that its nodes were read in the same directory nothing inside one claims that any node dispatched another. The account's five-hour and seven-day gauges sit above them, and one warning above the fleet names the unrecognised status rather than filing it as idle.">
79
+ alt="The tarmac map, live. Four framed groups, one per working directory and labelled with its project: beacon, holding a session halted on a permission prompt; harbor, holding a session busy with its context arc at 90% and a background agent docked under the cards of that frame as a strip named after its prompt; quay, reporting a status tarmac does not know; and atlas, idle at 36%. A frame says only that its nodes were read in the same directory. Nothing inside one claims that any node dispatched another. The account's five-hour and seven-day gauges sit above them, and one warning above the fleet names the unrecognised status rather than filing it as idle.">
70
80
  </picture>
71
81
 
72
- The tab in the header swaps the table for the same fleet as nodes one per session, the arc
73
- its context, the shape by the name its state. A background agent has no terminal behind it to
74
- draw a frame with, so it is a strip of text rather than a dial that could never fill. It is
75
- the same reading in the same fragment, so the two views can never disagree.
82
+ The tab in the header swaps the table for the same fleet drawn as nodes: one per session, the
83
+ arc its context, the shape by the name its state. A background agent has no terminal behind it
84
+ to draw a frame with, so it gets a strip of text rather than a dial that could never fill. Both
85
+ views render the same reading into the same fragment, so they can never disagree.
76
86
 
77
- The nodes are grouped by **working directory**: a frame per directory, labelled with its
78
- project, the sessions inside it as cards and the agents docked underneath as strips. That is
79
- the whole of what the frame claims — these were read in the same directory. It is not a
80
- parentage: `claude agents --json` publishes nothing that ties an agent to whoever dispatched
81
- it, so no label, no position and no line inside a frame says one node asked for another. An
82
- agent whose directory matches no session gets a frame of its own rather than somebody else's.
87
+ The nodes are grouped by working directory: a frame per directory, labelled with its project,
88
+ the sessions inside it as cards and the agents docked underneath as strips. A frame claims one
89
+ thing and no more: these nodes were read in the same directory. `claude agents --json`
90
+ publishes nothing that ties an agent to whoever dispatched it, so no label, no position and no
91
+ line inside a frame says one node asked for another. An agent whose directory matches no
92
+ session gets its own frame rather than somebody else's.
83
93
 
84
94
  Above the fleet are the account's five-hour and seven-day
85
- [gauges](docs/MANUAL.md#the-accounts-two-windows); under it a **scrubber over the day this
86
- serve has seen** drag it and the nodes render
87
- the fleet as it was at that minute, press play and the day walks past. Replayed, they are
88
- drawn flat and unframed: the record keeps a project name and never the directory it was read
89
- in, and a basename is not a directory — two checkouts of `atlas` answer to the same word, so a
90
- frame drawn on it would claim a shared directory nobody can check.
91
-
92
- The rules the table follows, the map follows: a reading past the freshness threshold is drawn
93
- thin, amber and dated, a percentage nobody measured is an empty dotted dial rather than a ring
94
- at zero, and where nothing was published at all the strip of an agent the join found no
95
- snapshot for nothing is drawn in its place, neither a dial nor a dash. A word
95
+ [gauges](docs/MANUAL.md#the-accounts-two-windows). Under it is a scrubber over the day this
96
+ serve has seen. Drag it and the nodes render the fleet as it was at that minute. Press play
97
+ and the day walks past. Replayed, the nodes are drawn flat and unframed. The record keeps a
98
+ project name and never the directory it was read in, and a basename is not a directory. Two
99
+ checkouts of `atlas` answer to the same word, so a frame drawn on it would claim a shared
100
+ directory nobody can check.
101
+
102
+ The map follows the rules the table follows. A reading past the freshness threshold is drawn
103
+ thin, amber and dated. A percentage nobody measured is an empty dotted dial, never a ring at
104
+ zero. Where nothing was published at all, such as the strip of an agent the join found no
105
+ snapshot for, nothing is drawn in its place: no dial, no dash. A word
96
106
  `claude agents --json` printed that tarmac has no boolean for is shown as it came rather than
97
- quietly filed as `idle` and raises one warning above the fleet naming it. The page is JSON
107
+ filed as `idle`, and raises one warning above the fleet naming it. The page is JSON
98
108
  underneath: `GET /api/fleet` and `GET /api/history` serve exactly what the map draws. Details
99
- in [the manual](docs/MANUAL.md#the-map).
109
+ are in [the manual](docs/MANUAL.md#the-map).
100
110
 
101
111
  ## Install
102
112
 
103
113
  `install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
104
- say-so alone. It prints the whole plan first including the exact command that undoes it
105
- and waits for a **typed word** (`y` is not an answer; scripts pass `--yes`, deliberately).
114
+ say-so alone. It prints the whole plan first, including the exact command that undoes it,
115
+ then waits for a typed word. `y` is not an answer. Scripts pass `--yes`, deliberately.
106
116
 
107
- A status line you already had is **wrapped, not replaced**: its display stays byte-identical,
108
- and `uninstall` names which of its four restore modes ran. Two files land under `~/.claude/`
109
- the wrapper and the `backup.json` that undoes it and neither changes at runtime: the
110
- snapshots go to `~/.local/state/tarmac/snapshots` (`$XDG_STATE_HOME` when set), because
111
- `~/.claude` is a directory people commit. The plan, the restore modes and the cleanup of the
112
- layout that kept snapshots inside `.claude`:
113
- [the manual](docs/MANUAL.md#installing-safely--the-full-contract).
117
+ A status line you already had is wrapped, not replaced: its display stays byte-identical, and
118
+ `uninstall` names which of its four restore modes ran. Two files land under `~/.claude/`, the
119
+ wrapper and the `backup.json` that undoes it, and neither changes at runtime. The snapshots go
120
+ to `~/.local/state/tarmac/snapshots` (`$XDG_STATE_HOME` when set), because `~/.claude` is a
121
+ directory people commit. The plan, the restore modes and the cleanup of the old layout that
122
+ kept snapshots inside `.claude` are in
123
+ [the manual](docs/MANUAL.md#installing-safely-the-full-contract).
114
124
 
115
125
  ## Why it does not break
116
126
 
117
127
  The usual ways to watch a Claude Code fleet read something Claude Code never promised would
118
- stay put: transcript files, terminal panes, undocumented paths. Those break on an update,
119
- and — worse they break *quietly*, reporting a calm empty fleet.
128
+ stay put: transcript files, terminal panes, undocumented paths. Those break on an update, and
129
+ worse, they break *quietly*, reporting a calm empty fleet.
120
130
 
121
131
  tarmac reads two things instead:
122
132
 
123
133
  | Source | What it gives | How solid |
124
134
  |---|---|---|
125
135
  | `claude agents --json` | which sessions exist, busy, idle or waiting on you, cwd, uptime | a documented CLI surface (`--help`: *"Print active sessions … as a JSON array … for scripting"*) |
126
- | 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** |
127
-
128
- That second line is the honest caveat, and it is the reason the real defence is not
129
- immunity, it is **visible degradation**. A missing measurement is never a confident `0`: it
130
- is an em dash that names which kind of missing it is `absent` for a session no status line
131
- ever wrote for, `fresh` for one that has taken no turn yet, `drift` for a release that moved
132
- the payload out from under us. A stale reading keeps its value and gets **dated** with a `!`.
133
- A Claude Code build no fixture covers gets its version named *before* anything breaks, and
134
- tarmac keeps reporting. The full state-by-state table is in
136
+ | 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 |
137
+
138
+ That second row is the honest caveat. tarmac does not promise the payload will hold still.
139
+ It promises that a measurement it could not take never renders as a confident `0`. What you
140
+ get instead is an em dash naming which kind of missing it is: `absent` for a session no status
141
+ line ever wrote for, `fresh` for one that has taken no turn yet, `drift` for a release that
142
+ moved the payload out from under us. A stale reading keeps its value and gets dated with a
143
+ `!`. A Claude Code build no fixture covers gets its version named *before* anything breaks,
144
+ and tarmac keeps reporting. The full state-by-state table is in
135
145
  [the manual](docs/MANUAL.md#degradation-state-by-state).
136
146
 
137
147
  ## Configuration
138
148
 
139
- Three numbers are opinions, not truths, so all three are yours; everything else is
140
- deliberately not configurable, and all of it works with no configuration at all.
149
+ Three numbers here are judgement calls, so all three are yours to set, and behind a reverse
150
+ proxy the hosts `serve` answers to are yours as well. Everything else is deliberately not
151
+ configurable, and all of it works with no configuration at all.
141
152
 
142
153
  | Setting | Flag | Default |
143
154
  |---|---|---|
144
155
  | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
145
156
  | port | `--port 8080` | `4477` |
146
157
  | snapshots dir (read side) | `--snapshots-dir DIR` | the wrapper's frozen path when installed, else the XDG state directory |
158
+ | trusted hosts | `--trust-host HOST`, once per host | none, so loopback only |
147
159
 
148
160
  Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
149
- **Flag beats environment beats config file beats default**, settled per setting; `serve`
150
- opens by printing each effective value and where it came from. Nothing is silently dropped
151
- or silently corrected a value that will not parse stops the run and says what it got,
152
- where it came from, and what would have worked. Spellings, edge cases and the two health
153
- fields `tarmac list --json` reports: [the manual](docs/MANUAL.md#configuration).
161
+ Flag beats environment beats config file beats default, settled per setting. `serve` opens by
162
+ printing each effective value and where it came from. Nothing is silently dropped or silently
163
+ corrected. A value that will not parse stops the run and says what it got, where it came from,
164
+ and what would have worked. Spellings, edge cases and the two health fields
165
+ `tarmac list --json` reports are in [the manual](docs/MANUAL.md#configuration).
154
166
 
155
167
  ## What it deliberately does not do
156
168
 
157
- - **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason
158
- a permission prompt, an open dialog and tarmac draws exactly that, on both surfaces.
159
- What it will not do is guess at the rest: a session that asked you a question in prose
160
- still reports `idle`, and the only way to know better is to read a transcript, which is
161
- the one thing this tool will not do. The signal is as good as the surface, and no better.
169
+ - **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason, such as
170
+ a permission prompt or an open dialog, and tarmac draws exactly that, in the table and on
171
+ the map. It does not guess at the rest. A session that asked you a question in prose still
172
+ reports `idle`, because the only way to know better is to read a transcript, and this tool
173
+ does not read transcripts.
162
174
  - **No history on disk.** `tarmac list` is a snapshot in time. A running `serve` holds the
163
- last 24 hours of the readings it took itself, in memory, so the page can replay them — a
164
- record that reaches no further back than the serve that took it, and goes when it goes.
165
- None of it is written down.
175
+ last 24 hours of the readings it took itself, in memory, so the page can replay them. That
176
+ record reaches no further back than the serve that took it, and goes when it goes. None of
177
+ it is written down.
166
178
  - **No Windows.** The generated wrapper is POSIX `sh`.
167
179
  - **No remote fleets.** It watches the machine it runs on.
168
180
 
@@ -174,9 +186,9 @@ npm run build # flat JavaScript into dist/
174
186
  node scripts/demo-fleet.ts # the invented fleet the captures above are taken of
175
187
  ```
176
188
 
177
- Every capture on this page is taken of a fleet that does not exist, because a screenshot of a
178
- real machine carries working directories, prompts and costs, and nothing real enters this
179
- repo. What CI covers, which Node version develops and which one ships:
189
+ Every capture on this page is taken of a fleet that does not exist. A screenshot of a real
190
+ machine carries working directories, prompts and costs, and nothing real enters this repo.
191
+ What CI covers, which Node version develops and which one ships:
180
192
  [the manual](docs/MANUAL.md#developing). Capturing the fixtures for a new Claude Code build:
181
193
  [the manual](docs/MANUAL.md#capturing-a-new-claude-code-version).
182
194
 
package/dist/args.js CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Unknown options and unknown commands are ERRORS. A typo silently ignored is how someone
5
5
  // ends up believing they pointed tarmac at a directory it never read.
6
- import { parsePort } from './config.js';
6
+ import { parsePort, parseTrustHost } from './config.js';
7
7
  const COMMANDS = new Set(['list', 'serve', 'install', 'uninstall', 'help']);
8
8
  // `| undefined` is load-bearing, not decoration: without it the compiler types the lookup
9
9
  // below as always-present and the `if (!key) throw` guard reads as dead code — which is how
@@ -12,28 +12,37 @@ const OPTIONS = {
12
12
  '--port': 'port',
13
13
  '--stale-after': 'staleAfter',
14
14
  '--snapshots-dir': 'snapshotsDir',
15
+ '--trust-host': 'trustHost',
15
16
  '--home': 'home',
16
17
  '--claude-bin': 'claudeBin',
17
18
  '--json': 'json',
18
19
  '--watch': 'watch',
19
20
  '--yes': 'yes',
20
21
  '--help': 'help',
22
+ '--version': 'version',
23
+ // The one short spelling this parser knows, and it is here because it is what people type
24
+ // before they read anything (#110). No other flag gets one: a letter is a scarce name, and
25
+ // `-h` for help would be the next request and the one after that.
26
+ '-v': 'version',
21
27
  };
22
- const FLAGS = new Set(['json', 'watch', 'yes', 'help']);
28
+ const FLAGS = new Set(['json', 'watch', 'yes', 'help', 'version']);
29
+ /** Options that ACCUMULATE rather than overwrite — passed twice, both values are kept. */
30
+ const LISTS = new Set(['trustHost']);
23
31
  /**
24
32
  * Which options each command really reads. The doc-comment above refuses a flag nobody
25
33
  * implements; this refuses a flag THIS command does not implement, which is the same defect
26
34
  * one level down — `tarmac serve --json` parsed cleanly and changed nothing, and from the
27
35
  * outside that is indistinguishable from a server that decided to answer HTML anyway.
28
36
  *
29
- * `help` is in every set on purpose: `--help` is answerable whatever else was typed.
37
+ * `help` is in every set on purpose: `--help` is answerable whatever else was typed, and
38
+ * `version` is there for the same reason — what build this is does not depend on the verb.
30
39
  */
31
40
  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']),
41
+ list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help', 'version']),
42
+ serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'home', 'claudeBin', 'help', 'version']),
43
+ install: new Set(['home', 'yes', 'help', 'version']),
44
+ uninstall: new Set(['home', 'yes', 'help', 'version']),
45
+ help: new Set(['help', 'version']),
37
46
  };
38
47
  /** The commands a misplaced flag would have been right on — an error that points somewhere. */
39
48
  function ownersOf(key) {
@@ -51,7 +60,7 @@ export function accepts(command, flag) {
51
60
  return key !== undefined && ACCEPTS[command].has(key);
52
61
  }
53
62
  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 };
63
+ const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false, version: false };
55
64
  let i = 0;
56
65
  if (argv[0] && !argv[0].startsWith('-')) {
57
66
  if (!COMMANDS.has(argv[0]))
@@ -81,6 +90,11 @@ export function parseArgs(argv) {
81
90
  // in the same words wherever it was set.
82
91
  out.port = parsePort(value, '--port');
83
92
  }
93
+ else if (LISTS.has(key)) {
94
+ // Same rule, for the same reason — and the value is kept as the guard will compare it,
95
+ // so what `serve` prints on startup is what it will actually match against.
96
+ out[key].push(parseTrustHost(value, flag));
97
+ }
84
98
  else {
85
99
  out[key] = value;
86
100
  }
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // `install` / `uninstall` default to the home this process runs under, print what they are
9
9
  // about to change, and proceed only on the typed verb (or `--yes`, in writing, for scripts).
10
+ import fs from 'node:fs';
10
11
  import os from 'node:os';
11
12
  import { setTimeout as sleep } from 'node:timers/promises';
12
13
  import { parseArgs } from './args.js';
@@ -24,13 +25,14 @@ const USAGE = `tarmac — fleet observability for Claude Code
24
25
  [--claude-bin PATH] [--json] [--watch]
25
26
  one-shot fleet table — with --watch, redrawn every 5s until ^C
26
27
  tarmac serve [--home DIR] [--port N] [--stale-after D] [--snapshots-dir DIR]
27
- [--claude-bin PATH]
28
+ [--claude-bin PATH] [--trust-host HOST]
28
29
  local dashboard
29
30
  tarmac install [--home DIR] [--yes]
30
31
  chain the statusline
31
32
  tarmac uninstall [--home DIR] [--yes]
32
33
  restore the statusline exactly
33
34
 
35
+ --version, -v print the version of this build and exit — on any command
34
36
  --watch redraw the table every 5s until ^C, dating every reading
35
37
  --home whose .claude to read or change (default: this home)
36
38
  --yes skip the typed confirmation — required when stdin is not a terminal
@@ -42,12 +44,32 @@ const USAGE = `tarmac — fleet observability for Claude Code
42
44
  (default: $XDG_STATE_HOME/tarmac/snapshots, or
43
45
  <home>/.local/state/tarmac/snapshots)
44
46
  --claude-bin path to the claude CLI (default: claude)
47
+ --trust-host a Host the dashboard also answers to, besides loopback — repeat it
48
+ once per host (default: none). For a reverse proxy: give the name
49
+ your browser shows, without the port, and remember that whoever can
50
+ reach that name can read this fleet
45
51
 
46
- Those three settings can also be set, in decreasing order of precedence, by the
47
- environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR) and by
48
- <home>/.claude/tarmac/config.json ({"staleAfterMs": …, "port": …, "snapshotsDir": …}).
49
- \`serve\` prints which one won.
52
+ Those four settings can also be set, in decreasing order of precedence, by the
53
+ environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR, TARMAC_TRUST_HOST)
54
+ and by <home>/.claude/tarmac/config.json ({"staleAfterMs": …, "port": …,
55
+ "snapshotsDir": …, "trustHosts": […]}). \`serve\` prints which one won.
50
56
  `;
57
+ /**
58
+ * What this build calls itself, read from the `package.json` that ships beside it — the one
59
+ * file npm puts in every tarball whatever `files` says, and the only place the number is
60
+ * written down. `dist/cli.js` sits one directory in exactly as `src/cli.ts` does, so a single
61
+ * relative path answers for the published CLI and for the suite, which runs the source.
62
+ *
63
+ * A version it could not read is not a version: rather than print an "unknown" nobody can act
64
+ * on, this throws and leaves through the same catch as every other refusal here.
65
+ */
66
+ function version() {
67
+ const at = new URL('../package.json', import.meta.url);
68
+ const pkg = JSON.parse(fs.readFileSync(at, 'utf8'));
69
+ if (typeof pkg.version !== 'string' || pkg.version === '')
70
+ throw new Error(`${at.href} carries no version`);
71
+ return pkg.version;
72
+ }
51
73
  try {
52
74
  // Parsing is inside the try so that a refusal — a typo'd flag, a duration nobody can read,
53
75
  // a config file with a key that does not exist — reaches the user as one line naming the
@@ -57,6 +79,13 @@ try {
57
79
  process.stdout.write(USAGE);
58
80
  process.exit(0);
59
81
  }
82
+ // After `--help`, which answers the wider question, and before any command has begun: a
83
+ // `tarmac install --version` that printed a plan and then waited for a typed word would be
84
+ // a one-line question that hangs a script. Bare, so something other than a person can read it.
85
+ if (args.version) {
86
+ process.stdout.write(`${version()}\n`);
87
+ process.exit(0);
88
+ }
60
89
  if (args.command === 'install' || args.command === 'uninstall') {
61
90
  const home = args.home ?? os.homedir();
62
91
  // The plan is computed first and printed whole: every refusal this operation has in it
@@ -101,7 +130,7 @@ try {
101
130
  if (frozen === null && wrapperIsOurs(p))
102
131
  console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
103
132
  const config = resolveConfig({
104
- flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir },
133
+ flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir, trustHosts: args.trustHost },
105
134
  env: process.env,
106
135
  file: readConfigFile(p.config),
107
136
  // The installed wrapper's own path, when there is one: the default is where the
@@ -124,6 +153,7 @@ try {
124
153
  console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
125
154
  const server = createFleetServer({
126
155
  collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
156
+ trustedHosts: config.trustHosts.value,
127
157
  });
128
158
  // A port nobody chose is not worth failing over: this walks past a busy 4477 and says
129
159
  // where it landed. A port that WAS chosen refuses instead, and the refusal leaves
package/dist/config.js CHANGED
@@ -65,7 +65,40 @@ function checkPort(n, label, shown = n) {
65
65
  }
66
66
  return n;
67
67
  }
68
- const KNOWN_KEYS = ['staleAfterMs', 'port', 'snapshotsDir'];
68
+ /**
69
+ * The name out of a `Host` header, or out of a setting that has to match one: the port
70
+ * dropped, the brackets of an IPv6 literal dropped with it.
71
+ *
72
+ * Shared with the server's guard so that BOTH sides of that comparison are cut the same way.
73
+ * A normaliser written twice is a guard with two definitions of the same name.
74
+ */
75
+ export const hostName = (host) => host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
76
+ /**
77
+ * A host `serve` will answer to besides loopback. Refuses everything that is not a name a
78
+ * `Host` header can carry — a scheme, a path, a wildcard, an IPv6 literal, an empty string —
79
+ * because each of those is accepted at the command line, printed on startup, and then matches
80
+ * nothing at all: a setting the tool appears to have taken and silently never applies.
81
+ *
82
+ * The port is DROPPED rather than refused, and the drop is visible in what `serve` prints. A
83
+ * proxy presents `name:8443` on one setup and a bare `name` on 443, and a list that matched
84
+ * the port would refuse half the setups it was typed for. It costs nothing: the port in a
85
+ * `Host` header is chosen by whoever sends it, so it never barred anybody.
86
+ *
87
+ * @param label how the source spells this setting, so the refusal names the knob to turn
88
+ */
89
+ export function parseTrustHost(text, label) {
90
+ // Lowered because host names are case-insensitive and browsers send them lowered — a 403
91
+ // over a capital would be a setting typed, accepted, and never matched. It gives nothing
92
+ // away: the name still has to be the one the reader wrote down, character for character.
93
+ const name = hostName(text.trim()).toLowerCase();
94
+ if (!HOST_NAME.test(name)) {
95
+ throw new Error(`${label} must be a host name like example.ts.net — no scheme, no path, no wildcard, got: ${format(text)}`);
96
+ }
97
+ return name;
98
+ }
99
+ /** Letters, digits, dots and dashes, starting and ending on one that is not a dash or a dot. */
100
+ const HOST_NAME = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
101
+ const KNOWN_KEYS = ['staleAfterMs', 'port', 'snapshotsDir', 'trustHosts'];
69
102
  /**
70
103
  * `~/.claude/tarmac/config.json`, if there is one.
71
104
  *
@@ -118,6 +151,20 @@ export function readConfigFile(file) {
118
151
  }
119
152
  out.snapshotsDir = v;
120
153
  }
154
+ if ('trustHosts' in body) {
155
+ const v = body.trustHosts;
156
+ if (!Array.isArray(v)) {
157
+ throw new Error(`${where('trustHosts')} must be an array of host names, got: ${format(v)}`);
158
+ }
159
+ out.trustHosts = v.map((h) => {
160
+ if (typeof h !== 'string') {
161
+ throw new Error(`${where('trustHosts')} must be an array of host names, got: ${format(h)}`);
162
+ }
163
+ // The same parser the flag and the environment go through: a host is refused, and kept,
164
+ // in the same words wherever it was written down.
165
+ return parseTrustHost(h, where('trustHosts'));
166
+ });
167
+ }
121
168
  return out;
122
169
  }
123
170
  /**
@@ -147,6 +194,9 @@ export function resolveConfig({ flags, env, file, defaultSnapshotsDir }) {
147
194
  const staleAfterEnv = parseIfSet(env.TARMAC_STALE_AFTER, (v) => parseDuration(v, 'TARMAC_STALE_AFTER'));
148
195
  const portEnv = parseIfSet(env.TARMAC_PORT, (v) => parsePort(v, 'TARMAC_PORT'));
149
196
  const dirEnv = read(env.TARMAC_SNAPSHOTS_DIR);
197
+ // Comma-separated, which a host name cannot contain — so the list needs no quoting rule of
198
+ // its own, and an empty item between two commas is refused rather than skipped.
199
+ const trustEnv = parseIfSet(env.TARMAC_TRUST_HOST, (v) => v.split(',').map((h) => parseTrustHost(h, 'TARMAC_TRUST_HOST')));
150
200
  return {
151
201
  staleAfterMs: flags.staleAfter !== null
152
202
  ? { value: parseDuration(flags.staleAfter, '--stale-after'), source: 'flag' }
@@ -169,6 +219,16 @@ export function resolveConfig({ flags, env, file, defaultSnapshotsDir }) {
169
219
  : file.snapshotsDir !== undefined
170
220
  ? { value: file.snapshotsDir, source: 'file' }
171
221
  : { value: defaultSnapshotsDir, source: 'default' },
222
+ // The winning rung is the WHOLE list, like every other setting here. Adding the four
223
+ // together would leave nobody able to narrow, for one run, a list a config file widened —
224
+ // and this is the one setting where widening is the whole of the risk.
225
+ trustHosts: flags.trustHosts.length > 0
226
+ ? { value: flags.trustHosts, source: 'flag' }
227
+ : trustEnv !== null
228
+ ? { value: trustEnv, source: 'env' }
229
+ : file.trustHosts !== undefined
230
+ ? { value: file.trustHosts, source: 'file' }
231
+ : { value: [], source: 'default' },
172
232
  };
173
233
  }
174
234
  /**
package/dist/fleet.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // whose clock floats dies quietly the day it crosses a threshold.
13
13
  import path from 'node:path';
14
14
  import { DEFAULT_STALE_AFTER_MS } from './config.js';
15
+ import { measured, windowsApart } from './limits.js';
15
16
  import { guardVersions } from './schema.js';
16
17
  import { isWaiting } from './sessions.js';
17
18
  import { SID_NAME } from './wrapper.js';
@@ -156,9 +157,15 @@ export function busyOnStaleFleet(rows) {
156
157
  * The account's rate limits, as this fleet's sessions report them.
157
158
  *
158
159
  * They belong to the ACCOUNT and not to any one session, but they arrive per snapshot — so the
159
- * rows do not carry contradicting numbers, they carry the same number at different ages, and
160
- * the youngest is the one still true. Same rule as everything else in this module: the freshest
161
- * reading wins.
160
+ * rows normally do not carry contradicting numbers, they carry the same number at different
161
+ * ages, and the youngest is the one still true. Same rule as everything else in this module:
162
+ * the freshest reading wins.
163
+ *
164
+ * "Normally", because one thing can make that rule wrong, and it is why the count travels with
165
+ * the winner: readings that name different resets are not one allowance seen twice (see
166
+ * `windowsApart`). The freshest is still what gets drawn — there is nothing better to draw —
167
+ * but how many readings it speaks for, and how many describe some other window, go out with it
168
+ * so that no surface can present a picked winner as the fleet's one account.
162
169
  *
163
170
  * A snapshot dated AFTER the clock that read it is refused rather than believed, which is the
164
171
  * verdict `map.ts` reaches on the same value: an NTP correction or a mount whose time runs
@@ -169,17 +176,69 @@ export function busyOnStaleFleet(rows) {
169
176
  * header draws it, and two copies of "which session's word counts" is two answers about one
170
177
  * account the day either one is touched.
171
178
  */
172
- export function accountLimits(rows) {
173
- let freshest = null;
174
- for (const r of rows) {
175
- if (r.rateLimits === null || r.snapshotAgeMs === null || r.snapshotAgeMs < 0)
179
+ export function accountLimits(rows, now) {
180
+ // Dated readings only, and the same two exclusions on both sides of the question: a reading
181
+ // nothing can date cannot win on freshness, so it does not get to disagree either — it would
182
+ // put a number in the denominator that no surface is allowed to show.
183
+ const dated = rows
184
+ .filter((r) => r.rateLimits !== null && r.snapshotAgeMs !== null && r.snapshotAgeMs >= 0)
185
+ .map((r) => ({
186
+ rateLimits: r.rateLimits,
187
+ ageMs: r.snapshotAgeMs,
188
+ sessionId: r.sessionId,
189
+ measured: measured(r.rateLimits, now),
190
+ }));
191
+ if (dated.length === 0)
192
+ return null;
193
+ // Freshest AND measured, in that order. A session that has just started is guaranteed to be
194
+ // the youngest snapshot on the machine and is the likeliest to carry a window whose number
195
+ // has not been taken yet — freshest alone let it blank an account three other sessions were
196
+ // reporting. When nothing measured anything, the freshest still comes back, so the surfaces
197
+ // say "no reading" about a reading that exists rather than about no reading at all.
198
+ const readings = dated.filter((r) => r.measured);
199
+ const freshest = youngest(readings.length > 0 ? readings : dated);
200
+ // Every reading that measured something, the shown one included: it is compared with itself,
201
+ // which is the one comparison that can never come back apart. Skipping it explicitly was a
202
+ // line no test could ever have killed.
203
+ const apartWindows = new Set();
204
+ let apart = 0;
205
+ for (const r of readings) {
206
+ const windows = windowsApart(freshest.rateLimits, r.rateLimits, now);
207
+ if (windows.length === 0)
176
208
  continue;
177
- if (freshest === null || r.snapshotAgeMs < freshest.ageMs) {
178
- freshest = { rateLimits: r.rateLimits, ageMs: r.snapshotAgeMs };
179
- }
209
+ apart += 1;
210
+ for (const w of windows)
211
+ apartWindows.add(w);
212
+ }
213
+ return {
214
+ rateLimits: freshest.rateLimits,
215
+ ageMs: freshest.ageMs,
216
+ readings: readings.length,
217
+ apart,
218
+ apartWindows: [...apartWindows],
219
+ };
220
+ }
221
+ /**
222
+ * The youngest reading, with a tie settled by session id rather than by the order the sources
223
+ * printed — the rule `preferred()` already applies to two snapshot files of the same age, for
224
+ * the same reason. `rows` is sorted on a key that is not total, so equal-aged readings keep
225
+ * whatever order `claude agents --json` emitted them in: without this, the number drawn AND the
226
+ * count published beside it move between two machines reading the same fleet. Which one it
227
+ * picks matters far less than that it picks the same one every time.
228
+ *
229
+ * An id nothing reported sorts last: it is the one value that cannot tell two readings apart.
230
+ */
231
+ function youngest(readings) {
232
+ let best = readings[0];
233
+ for (const r of readings) {
234
+ if (r.ageMs < best.ageMs)
235
+ best = r;
236
+ else if (r.ageMs === best.ageMs && sortKey(r.sessionId) < sortKey(best.sessionId))
237
+ best = r;
180
238
  }
181
- return freshest;
239
+ return best;
182
240
  }
241
+ const sortKey = (sessionId) => (sessionId === null ? '￿' : sessionId);
183
242
  // Blocked on a human first, then busy, then unknown (it might be busy), then idle.
184
243
  //
185
244
  // Waiting leads because it is the only rank that is work for the reader: everything below it
package/dist/history.js CHANGED
@@ -83,6 +83,6 @@ function sampleOf({ rows, health }) {
83
83
  costUsd: r.costUsd,
84
84
  })),
85
85
  // The reading, without its age: the ring keeps each reading and never how old it was.
86
- rateLimits: accountLimits(rows)?.rateLimits ?? null,
86
+ rateLimits: accountLimits(rows, health.generatedAt)?.rateLimits ?? null,
87
87
  };
88
88
  }