@adrrr/tarmac 0.4.1 → 0.6.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 with the scrubber dragged across the record: five sessions drawn as dials, each arc a context window, the account's five-hour and seven-day gauges above them. As the handle moves, 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.">
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,112 +42,135 @@ 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 is the account's, not any
49
+ session's: 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, dated like every
51
+ other reading here, and `— no reading` rather than `0%` when no snapshot carried one.
51
52
 
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:
53
+ With nothing installed, the same command still lists every session, its state and its uptime,
54
+ straight from `claude agents --json`. The context column reads `— absent`, model, effort and
55
+ cost fall to `—`, and the line under the table counts how many sessions are covered.
56
+
57
+ Node ≥ 20. Zero runtime dependencies: no framework, no bundler, nothing to audit.
58
+ `--help` works everywhere. An option handed to a command that does not read it is an error,
59
+ not something quietly ignored. Every command, flag and route:
55
60
  [the manual](docs/MANUAL.md#commands-and-options).
56
61
 
57
62
  ## The map
58
63
 
59
- `tarmac serve` puts the same fleet in the browser every session a row, ages that keep
64
+ `tarmac serve` puts the same fleet in the browser: every session a row, ages that keep
60
65
  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 does
62
- not mark same-origin: your cwd paths and costs never leave the machine. The listening rules:
63
- [the manual](docs/MANUAL.md#what-serve-listens-on).
66
+ binds to loopback and refuses any request whose `Host` is not loopback, or that a browser
67
+ marks as coming from another origin, so your cwd paths and costs never leave the machine. A
68
+ client that sends no such mark, curl or a script, is left alone. Behind a reverse proxy, which
69
+ presents a `Host` of its own, `--trust-host <name>` names the one to let through and says who
70
+ that lets in: [the manual](docs/MANUAL.md#putting-it-behind-a-reverse-proxy). The listening
71
+ rules: [the manual](docs/MANUAL.md#what-serve-listens-on).
64
72
 
65
73
  <picture>
66
74
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
67
75
  <img src="docs/media/map-light.png" width="1100"
68
- alt="The tarmac map, live. Five nodes: beacon halted on a permission prompt, harbor busy with its context arc at 90%, a background agent named after its prompt beside it, quay reporting a status tarmac does not know, and atlas idle at 36%. 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.">
76
+ 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.">
69
77
  </picture>
70
78
 
71
- The tab in the header swaps the table for the same fleet as nodes one per session, the arc
72
- its context, the shape by the name its state. It is the same reading in the same fragment, so
73
- the two views can never disagree. Above the fleet are the account's five-hour and seven-day
74
- [gauges](docs/MANUAL.md#the-accounts-two-windows); under it a **scrubber over the day this
75
- serve has seen** — drag it and the dials render
76
- the fleet as it was at that minute, press play and the day walks past.
77
-
78
- The rules the table follows, the map follows: a reading past the freshness threshold is drawn
79
- thin, amber and dated, a percentage nobody measured is an empty dotted dial rather than a ring
80
- at zero, and a word `claude agents --json` printed that tarmac has no boolean for is shown as
81
- it came rather than quietly filed as `idle` and raises one warning above the fleet naming
82
- it. The page is JSON underneath: `GET /api/fleet` and `GET /api/history` serve exactly what
83
- the map draws. Details in
84
- [the manual](docs/MANUAL.md#the-map).
79
+ The tab in the header swaps the table for the same fleet as nodes: one per session, the arc
80
+ its context, the shape by the name its state. A background agent has no terminal behind it to
81
+ draw a frame with, so it gets a strip of text rather than a dial that could never fill. Both
82
+ views render the same reading into the same fragment, so they can never disagree.
83
+
84
+ The nodes are grouped by working directory: a frame per directory, labelled with its project,
85
+ the sessions inside it as cards and the agents docked underneath as strips. That is the whole
86
+ of what the frame claims: these were read in the same directory. It is not a parentage.
87
+ `claude agents --json` publishes nothing that ties an agent to whoever dispatched it, so no
88
+ label, no position and no line inside a frame says one node asked for another. An agent whose
89
+ directory matches no session gets a frame of its own rather than somebody else's.
90
+
91
+ Above the fleet are the account's five-hour and seven-day
92
+ [gauges](docs/MANUAL.md#the-accounts-two-windows); under it a scrubber over the day this
93
+ serve has seen. Drag it and the nodes render the fleet as it was at that minute, press play
94
+ and the day walks past. Replayed, they are drawn flat and unframed: the record keeps a project
95
+ name and never the directory it was read in, and a basename is not a directory. Two checkouts
96
+ of `atlas` answer to the same word, so a frame drawn on it would claim a shared directory
97
+ nobody can check.
98
+
99
+ The map follows the rules the table follows. A reading past the freshness threshold is drawn
100
+ thin, amber and dated. A percentage nobody measured is an empty dotted dial rather than a ring
101
+ at zero. Where nothing was published at all, such as the strip of an agent the join found no
102
+ snapshot for, nothing is drawn in its place, neither a dial nor a dash. A word
103
+ `claude agents --json` printed that tarmac has no boolean for is shown as it came rather than
104
+ filed as `idle`, and raises one warning above the fleet naming it. The page is JSON
105
+ underneath: `GET /api/fleet` and `GET /api/history` serve exactly what the map draws. Details
106
+ in [the manual](docs/MANUAL.md#the-map).
85
107
 
86
108
  ## Install
87
109
 
88
110
  `install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
89
- say-so alone. It prints the whole plan first including the exact command that undoes it
90
- and waits for a **typed word** (`y` is not an answer; scripts pass `--yes`, deliberately).
111
+ say-so alone. It prints the whole plan first, including the exact command that undoes it,
112
+ then waits for a typed word. `y` is not an answer; scripts pass `--yes`, deliberately.
91
113
 
92
- A status line you already had is **wrapped, not replaced**: its display stays byte-identical,
93
- and `uninstall` names which of its four restore modes ran. Two files land under `~/.claude/`
94
- the wrapper and the `backup.json` that undoes it and neither changes at runtime: the
95
- snapshots go to `~/.local/state/tarmac/snapshots` (`$XDG_STATE_HOME` when set), because
96
- `~/.claude` is a directory people commit. The plan, the restore modes and the cleanup of the
97
- layout that kept snapshots inside `.claude`:
98
- [the manual](docs/MANUAL.md#installing-safely--the-full-contract).
114
+ A status line you already had is wrapped, not replaced: its display stays byte-identical, and
115
+ `uninstall` names which of its four restore modes ran. Two files land under `~/.claude/`, the
116
+ wrapper and the `backup.json` that undoes it, and neither changes at runtime. The snapshots go
117
+ to `~/.local/state/tarmac/snapshots` (`$XDG_STATE_HOME` when set), because `~/.claude` is a
118
+ directory people commit. The plan, the restore modes and the cleanup of the layout that kept
119
+ snapshots inside `.claude`:
120
+ [the manual](docs/MANUAL.md#installing-safely-the-full-contract).
99
121
 
100
122
  ## Why it does not break
101
123
 
102
124
  The usual ways to watch a Claude Code fleet read something Claude Code never promised would
103
- stay put: transcript files, terminal panes, undocumented paths. Those break on an update,
104
- and — worse they break *quietly*, reporting a calm empty fleet.
125
+ stay put: transcript files, terminal panes, undocumented paths. Those break on an update, and
126
+ worse, they break *quietly*, reporting a calm empty fleet.
105
127
 
106
128
  tarmac reads two things instead:
107
129
 
108
130
  | Source | What it gives | How solid |
109
131
  |---|---|---|
110
132
  | `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"*) |
111
- | 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** |
133
+ | 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 |
112
134
 
113
- That second line is the honest caveat, and it is the reason the real defence is not
114
- immunity, it is **visible degradation**. A missing measurement is never a confident `0`: it
115
- is an em dash that names which kind of missing it is `absent` for a session no status line
116
- ever wrote for, `fresh` for one that has taken no turn yet, `drift` for a release that moved
117
- the payload out from under us. A stale reading keeps its value and gets **dated** with a `!`.
118
- A Claude Code build no fixture covers gets its version named *before* anything breaks, and
119
- tarmac keeps reporting. The full state-by-state table is in
120
- [the manual](docs/MANUAL.md#degradation-state-by-state).
135
+ That second line is the honest caveat. The real defence is not immunity, it is visible
136
+ degradation. A missing measurement is never a confident `0`. It is an em dash that names which
137
+ kind of missing it is: `absent` for a session no status line ever wrote for, `fresh` for one
138
+ that has taken no turn yet, `drift` for a release that moved the payload out from under us. A
139
+ stale reading keeps its value and gets dated with a `!`. A Claude Code build no fixture covers
140
+ gets its version named *before* anything breaks, and tarmac keeps reporting. The full
141
+ state-by-state table is in [the manual](docs/MANUAL.md#degradation-state-by-state).
121
142
 
122
143
  ## Configuration
123
144
 
124
- Three numbers are opinions, not truths, so all three are yours; everything else is
125
- deliberately not configurable, and all of it works with no configuration at all.
145
+ Three numbers are opinions rather than truths, so all three are yours, and behind a reverse
146
+ proxy the hosts `serve` answers to are yours as well. Everything else is deliberately not
147
+ configurable, and all of it works with no configuration at all.
126
148
 
127
149
  | Setting | Flag | Default |
128
150
  |---|---|---|
129
151
  | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
130
152
  | port | `--port 8080` | `4477` |
131
- | snapshots dir (read side) | `--snapshots-dir DIR` | the path frozen into the installed wrapper, so the reader follows the writer |
153
+ | snapshots dir (read side) | `--snapshots-dir DIR` | the wrapper's frozen path when installed, else the XDG state directory |
154
+ | trusted hosts | `--trust-host HOST`, once per host | none — loopback only |
132
155
 
133
156
  Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
134
- **Flag beats environment beats config file beats default**, settled per setting; `serve`
135
- opens by printing each effective value and where it came from. Nothing is silently dropped
136
- or silently corrected a value that will not parse stops the run and says what it got,
137
- where it came from, and what would have worked. Spellings, edge cases and the two health
138
- fields `tarmac list --json` reports: [the manual](docs/MANUAL.md#configuration).
157
+ Flag beats environment beats config file beats default, settled per setting; `serve` opens by
158
+ printing each effective value and where it came from. Nothing is silently dropped or silently
159
+ corrected. A value that will not parse stops the run and says what it got, where it came from,
160
+ and what would have worked. Spellings, edge cases and the two health fields
161
+ `tarmac list --json` reports: [the manual](docs/MANUAL.md#configuration).
139
162
 
140
163
  ## What it deliberately does not do
141
164
 
142
- - **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason
143
- a permission prompt, an open dialog and tarmac draws exactly that, on both surfaces.
144
- What it will not do is guess at the rest: a session that asked you a question in prose
165
+ - **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason, such as
166
+ a permission prompt or an open dialog, and tarmac draws exactly that, in the table and on
167
+ the map. What it will not do is guess at the rest: a session that asked you a question in prose
145
168
  still reports `idle`, and the only way to know better is to read a transcript, which is
146
- the one thing this tool will not do. The signal is as good as the surface, and no better.
169
+ the one thing this tool will not do. The signal is as good as its source, and no better.
147
170
  - **No history on disk.** `tarmac list` is a snapshot in time. A running `serve` holds the
148
- last 24 hours of the readings it took itself, in memory, so the page can replay them — a
149
- record that reaches no further back than the serve that took it, and goes when it goes.
150
- None of it is written down.
171
+ last 24 hours of the readings it took itself, in memory, so the page can replay them. That
172
+ record reaches no further back than the serve that took it, and goes when it goes. None of
173
+ it is written down.
151
174
  - **No Windows.** The generated wrapper is POSIX `sh`.
152
175
  - **No remote fleets.** It watches the machine it runs on.
153
176
 
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,6 +12,7 @@ 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',
@@ -20,6 +21,8 @@ const OPTIONS = {
20
21
  '--help': 'help',
21
22
  };
22
23
  const FLAGS = new Set(['json', 'watch', 'yes', 'help']);
24
+ /** Options that ACCUMULATE rather than overwrite — passed twice, both values are kept. */
25
+ const LISTS = new Set(['trustHost']);
23
26
  /**
24
27
  * Which options each command really reads. The doc-comment above refuses a flag nobody
25
28
  * implements; this refuses a flag THIS command does not implement, which is the same defect
@@ -30,7 +33,7 @@ const FLAGS = new Set(['json', 'watch', 'yes', 'help']);
30
33
  */
31
34
  const ACCEPTS = {
32
35
  list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help']),
33
- serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'help']),
36
+ serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'home', 'claudeBin', 'help']),
34
37
  install: new Set(['home', 'yes', 'help']),
35
38
  uninstall: new Set(['home', 'yes', 'help']),
36
39
  help: new Set(['help']),
@@ -51,7 +54,7 @@ export function accepts(command, flag) {
51
54
  return key !== undefined && ACCEPTS[command].has(key);
52
55
  }
53
56
  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 };
57
+ const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false };
55
58
  let i = 0;
56
59
  if (argv[0] && !argv[0].startsWith('-')) {
57
60
  if (!COMMANDS.has(argv[0]))
@@ -81,6 +84,11 @@ export function parseArgs(argv) {
81
84
  // in the same words wherever it was set.
82
85
  out.port = parsePort(value, '--port');
83
86
  }
87
+ else if (LISTS.has(key)) {
88
+ // Same rule, for the same reason — and the value is kept as the guard will compare it,
89
+ // so what `serve` prints on startup is what it will actually match against.
90
+ out[key].push(parseTrustHost(value, flag));
91
+ }
84
92
  else {
85
93
  out[key] = value;
86
94
  }
package/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ const USAGE = `tarmac — fleet observability for Claude Code
24
24
  [--claude-bin PATH] [--json] [--watch]
25
25
  one-shot fleet table — with --watch, redrawn every 5s until ^C
26
26
  tarmac serve [--home DIR] [--port N] [--stale-after D] [--snapshots-dir DIR]
27
- [--claude-bin PATH]
27
+ [--claude-bin PATH] [--trust-host HOST]
28
28
  local dashboard
29
29
  tarmac install [--home DIR] [--yes]
30
30
  chain the statusline
@@ -42,11 +42,15 @@ const USAGE = `tarmac — fleet observability for Claude Code
42
42
  (default: $XDG_STATE_HOME/tarmac/snapshots, or
43
43
  <home>/.local/state/tarmac/snapshots)
44
44
  --claude-bin path to the claude CLI (default: claude)
45
+ --trust-host a Host the dashboard also answers to, besides loopback — repeat it
46
+ once per host (default: none). For a reverse proxy: give the name
47
+ your browser shows, without the port, and remember that whoever can
48
+ reach that name can read this fleet
45
49
 
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.
50
+ Those four settings can also be set, in decreasing order of precedence, by the
51
+ environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR, TARMAC_TRUST_HOST)
52
+ and by <home>/.claude/tarmac/config.json ({"staleAfterMs": …, "port": …,
53
+ "snapshotsDir": …, "trustHosts": […]}). \`serve\` prints which one won.
50
54
  `;
51
55
  try {
52
56
  // Parsing is inside the try so that a refusal — a typo'd flag, a duration nobody can read,
@@ -101,7 +105,7 @@ try {
101
105
  if (frozen === null && wrapperIsOurs(p))
102
106
  console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
103
107
  const config = resolveConfig({
104
- flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir },
108
+ flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir, trustHosts: args.trustHost },
105
109
  env: process.env,
106
110
  file: readConfigFile(p.config),
107
111
  // The installed wrapper's own path, when there is one: the default is where the
@@ -124,6 +128,7 @@ try {
124
128
  console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
125
129
  const server = createFleetServer({
126
130
  collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
131
+ trustedHosts: config.trustHosts.value,
127
132
  });
128
133
  // A port nobody chose is not worth failing over: this walks past a busy 4477 and says
129
134
  // 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
  }
package/dist/limits.js CHANGED
@@ -37,18 +37,15 @@ export const LIMIT_WINDOWS = [
37
37
  export const RESET_HORIZON_MS = 8 * 24 * 3600 * 1000;
38
38
  /** Both windows, always — a window that could not be read is a gauge that says so. */
39
39
  export function readLimits(rateLimits, now) {
40
- // `rate_limits: []` and `rate_limits: "none"` are legal JSON and not a pair of windows.
41
- // Neither may reach the lookup below as something to index.
42
- const ok = rateLimits !== null && typeof rateLimits === 'object' && !Array.isArray(rateLimits);
43
40
  return LIMIT_WINDOWS.map(({ key, label, said }) => {
44
- const w = ok ? rateLimits[key] : undefined;
45
- const has = w !== null && typeof w === 'object' && !Array.isArray(w) && 'used_percentage' in w;
46
- const v = has ? w.used_percentage : undefined;
41
+ const w = windowAt(rateLimits, key);
42
+ const has = w !== undefined && 'used_percentage' in w;
43
+ const v = w?.used_percentage;
47
44
  // Present and null: a window whose number has not been taken yet. Absent, or holding
48
45
  // something that is not a percentage: the shape moved. The discriminant is the key.
49
46
  const pct = has && typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? Math.floor(v) : null;
50
47
  const why = pct !== null ? null : rateLimits == null || (has && v === null) ? 'absent' : 'drift';
51
- const at = has && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;
48
+ const at = has ? resetOf(w) : null;
52
49
  const resetsInMs = at === null ? null : at * 1000 - now;
53
50
  return {
54
51
  key,
@@ -60,3 +57,76 @@ export function readLimits(rateLimits, now) {
60
57
  };
61
58
  });
62
59
  }
60
+ /**
61
+ * The windows two readings of the account describe DIFFERENTLY, by key, in the order above —
62
+ * meaning two windows that are BOTH still open at `now` and are not the same window.
63
+ *
64
+ * A fleet holds one reading per session and only one of them can be drawn, so the question is
65
+ * what the others were. It is settled on the reset and not the percentage: `resets_at` is where
66
+ * a window ENDS, so two readings naming the same one are two ages of a single allowance — the
67
+ * freshest is the one still true, and the age beside it says the rest. Percentages that differ
68
+ * under one reset are that same number caught at two frames, the normal state of a fleet, and
69
+ * warning about it would be a warning on every poll.
70
+ *
71
+ * The openness test is the other half, and without it this cries wolf every five hours. A
72
+ * session that idles keeps the frame it last drew, and the five-hour window rolls over four or
73
+ * five times a day: an overnight snapshot names the window it was taken in, which has since
74
+ * ended. That is not two accounts, it is one reading being old — a fact the fleet already
75
+ * prints, as that row's age and as the `!` beside it — so a window whose boundary is behind
76
+ * `now` is left out of the comparison rather than raised as a disagreement.
77
+ *
78
+ * What survives both rules is the thing nothing else on either surface can say: two windows
79
+ * open AT THE SAME TIME, which one allowance cannot have. Whether that is two accounts signed
80
+ * in at once or something stranger is published nowhere tarmac reads, so this reports that the
81
+ * readings are apart and never why.
82
+ *
83
+ * A reading that dates no window is not a reading that dates one differently: an absent
84
+ * boundary is compared with nothing, exactly as an absent percentage is drawn as nothing. And
85
+ * a boundary further out than `RESET_HORIZON_MS` is refused here as it is refused a countdown —
86
+ * a reset fifty thousand years away is not a window this account is in.
87
+ *
88
+ * Known blind spot, and the reason it is left open: two accounts whose windows happen to end at
89
+ * the same second read as one here, and their percentages then differ in silence. The only
90
+ * thing that would catch it is treating a percentage as evidence — and the shape that takes is
91
+ * "the fresher reading is lower than the older one", which cannot be true of one allowance and
92
+ * would be a false alarm the day a number is ever revised downward. A missed collision costs a
93
+ * warning nobody sees; the other rule costs a warning nobody can act on, on a fleet where
94
+ * nothing is wrong.
95
+ */
96
+ export function windowsApart(a, b, now) {
97
+ const apart = [];
98
+ for (const { key } of LIMIT_WINDOWS) {
99
+ const at = openBoundary(windowAt(a, key), now);
100
+ const bt = openBoundary(windowAt(b, key), now);
101
+ if (at !== null && bt !== null && at !== bt)
102
+ apart.push(key);
103
+ }
104
+ return apart;
105
+ }
106
+ /** Whether this reading yielded a number for either window — a reading that measured something. */
107
+ export const measured = (rateLimits, now) => readLimits(rateLimits, now).some((g) => g.pct !== null);
108
+ /**
109
+ * The epoch a window rolls over at, when that window is still OPEN at `now` — and `null` for
110
+ * one that has already rolled over, one nothing dates, and one dated beyond the horizon.
111
+ */
112
+ function openBoundary(w, now) {
113
+ const at = resetOf(w);
114
+ if (at === null)
115
+ return null;
116
+ const inMs = at * 1000 - now;
117
+ return inMs > 0 && inMs <= RESET_HORIZON_MS ? at : null;
118
+ }
119
+ /**
120
+ * The window filed under `key`, or `undefined` when the payload carries nothing usable there.
121
+ *
122
+ * `rate_limits: []` and `rate_limits: "none"` are legal JSON and not a pair of windows, and
123
+ * neither may reach an index or a property read as something to look inside.
124
+ */
125
+ function windowAt(rateLimits, key) {
126
+ if (rateLimits === null || rateLimits === undefined || typeof rateLimits !== 'object' || Array.isArray(rateLimits))
127
+ return undefined;
128
+ const w = rateLimits[key];
129
+ return w !== null && typeof w === 'object' && !Array.isArray(w) ? w : undefined;
130
+ }
131
+ /** The epoch a window rolls over at, or `null` when this reading does not name one. */
132
+ const resetOf = (w) => w !== undefined && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;