@adrrr/tarmac 0.5.0 → 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, 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,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 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).
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).
65
72
 
66
73
  <picture>
67
74
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
68
75
  <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.">
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.">
70
77
  </picture>
71
78
 
72
- The tab in the header swaps the table for the same fleet as nodes one per session, the arc
79
+ The tab in the header swaps the table for the same fleet as nodes: one per session, the arc
73
80
  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.
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.
76
83
 
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.
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.
83
90
 
84
91
  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
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
96
103
  `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
104
+ filed as `idle`, and raises one warning above the fleet naming it. The page is JSON
98
105
  underneath: `GET /api/fleet` and `GET /api/history` serve exactly what the map draws. Details
99
106
  in [the manual](docs/MANUAL.md#the-map).
100
107
 
101
108
  ## Install
102
109
 
103
110
  `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).
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.
106
113
 
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).
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).
114
121
 
115
122
  ## Why it does not break
116
123
 
117
124
  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.
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.
120
127
 
121
128
  tarmac reads two things instead:
122
129
 
123
130
  | Source | What it gives | How solid |
124
131
  |---|---|---|
125
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"*) |
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** |
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 |
127
134
 
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
135
- [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).
136
142
 
137
143
  ## Configuration
138
144
 
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.
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.
141
148
 
142
149
  | Setting | Flag | Default |
143
150
  |---|---|---|
144
151
  | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
145
152
  | port | `--port 8080` | `4477` |
146
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 |
147
155
 
148
156
  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).
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).
154
162
 
155
163
  ## What it deliberately does not do
156
164
 
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
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
160
168
  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
+ the one thing this tool will not do. The signal is as good as its source, and no better.
162
170
  - **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.
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.
166
174
  - **No Windows.** The generated wrapper is POSIX `sh`.
167
175
  - **No remote fleets.** It watches the machine it runs on.
168
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;
package/dist/render.js CHANGED
@@ -137,6 +137,12 @@ export function renderSettings(config, configFile) {
137
137
  ['port', String(config.port.value), config.port.source],
138
138
  ['snapshots', config.snapshotsDir.value, config.snapshotsDir.source],
139
139
  ];
140
+ // Only when there are any. An empty list is what every other run has, chosen by nobody —
141
+ // a `(default)` line saying "none" on every serve is noise, and this line has to read as
142
+ // what it is: the one setting that widened who may read this port.
143
+ if (config.trustHosts.value.length > 0) {
144
+ rows.push(['trusted', config.trustHosts.value.join(', '), config.trustHosts.source]);
145
+ }
140
146
  // Only the LABEL column is padded. Padding the values aligned the sources against the
141
147
  // snapshots path, which is absolute — pushing the one word that says where a value came
142
148
  // from past the edge of an 80-column terminal.
@@ -157,7 +163,7 @@ export function renderTable({ rows, health }) {
157
163
  r.effort ?? '—',
158
164
  r.costUsd === null ? '—' : `$${r.costUsd.toFixed(2)}`,
159
165
  r.uptimeMs === null ? '—' : `${Math.round(r.uptimeMs / 3600000)}h`,
160
- ]);
166
+ ].map(clip));
161
167
  const w = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
162
168
  const line = (cells) => cells.map((c, i) => c.padEnd(w[i])).join(' ').trimEnd();
163
169
  const warns = [];
@@ -192,6 +198,11 @@ export function renderTable({ rows, health }) {
192
198
  warns.push(`! ${skewed} reading(s) are dated in the future — ${SKEW}`);
193
199
  if (health.unknownStatus > 0)
194
200
  warns.push(`! ${health.unknownStatus} session(s) report an unknown status`);
201
+ const account = accountLimits(rows, health.generatedAt);
202
+ const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt);
203
+ const split = accountSplit(account, gauges);
204
+ if (split)
205
+ warns.push(`! ${split}`);
195
206
  // Last, and never instead of anything above: this one is a heads-up, not a fault.
196
207
  const schema = schemaNotice(health.schemaGuard);
197
208
  if (schema)
@@ -200,7 +211,86 @@ export function renderTable({ rows, health }) {
200
211
  return ([line(head), ...body.map(line)].join('\n') +
201
212
  '\n' +
202
213
  (warns.length ? '\n' + warns.join('\n') + '\n' : '') +
203
- `\n${health.sessions} sessions · ${health.busy} busy · ${total}\n`);
214
+ `\n${health.sessions} sessions · ${health.busy} busy · ${total}\n${accountLine(gauges, account, health)}\n`);
215
+ }
216
+ /**
217
+ * The account's two windows, under the fleet rather than in a column.
218
+ *
219
+ * They are the one pair of numbers in this table that is not about a session: every row above
220
+ * spends from the same five-hour and seven-day allowance, so a column of them would be the
221
+ * same two numbers printed once per session. Under the totals, where the other fleet-wide
222
+ * facts are.
223
+ *
224
+ * Dated like every reading here, and always: the AS OF column exists because a percentage is
225
+ * as old as the frame that wrote it, and this one has no column to be dated by. The `!` is the
226
+ * same mark, past the same threshold, explained by the same warning above.
227
+ */
228
+ function accountLine(gauges, account, health) {
229
+ const windows = gauges
230
+ .map((g) => `${g.label} ${g.pct === null ? `— ${LIMIT_WHY[g.why]}` : `${g.pct}% ${resetWords(g.resetsInMs, '—')}`}`)
231
+ .join(' · ');
232
+ // A reading is dated; no reading is not. The two states read alike in the windows above —
233
+ // `— no reading` is what a payload with no rate limits and a fleet with no snapshot at all
234
+ // both come to — and the age is what tells them apart: a snapshot that said nothing carries
235
+ // the moment it said it, and a fleet nothing was read for has no such moment to print.
236
+ const as = account === null ? '' : ` · as of ${age(account.ageMs)}${account.ageMs > health.staleAfterMs ? ' !' : ''}`;
237
+ return `account ${windows}${as}`;
238
+ }
239
+ /**
240
+ * What to say when the readings behind that line are not all about the same windows, and
241
+ * `null` on the ordinary fleet, where they are.
242
+ *
243
+ * One warning for both surfaces to be written from: the account is the ONE number here picked
244
+ * out of several that could have been it, and a picked winner presented as the fleet's account
245
+ * is exactly what a fleet signed into two logins at once would look like. The count is what the
246
+ * reader needs in order to go and look; WHY two windows were open at the same time is published
247
+ * nowhere tarmac reads, so it is not guessed.
248
+ *
249
+ * Only windows that are drawn as a number, because this sentence qualifies one: a window the
250
+ * surface prints as `— schema drift` has nothing for "the freshest is shown" to be true of, and
251
+ * a warning derived from a field the line under it has just called unreadable is a warning about
252
+ * the wrong thing. When that leaves nothing to name, there is nothing to say.
253
+ */
254
+ function accountSplit(account, gauges) {
255
+ if (account === null || account.apart === 0)
256
+ return null;
257
+ const drawn = new Set(gauges.filter((g) => g.pct !== null).map((g) => g.key));
258
+ const labels = LIMIT_WINDOWS.filter((w) => account.apartWindows.includes(w.key) && drawn.has(w.key)).map((w) => w.label);
259
+ if (labels.length === 0)
260
+ return null;
261
+ const which = `the ${labels.join(' and ')} window${labels.length === 1 ? '' : 's'}`;
262
+ // Said the long way round on purpose: "1 of 4 readings names" and "2 of 4 readings name" are
263
+ // two sentences, and a count that has to agree with a verb is a count someone will get wrong.
264
+ return `${which} ${labels.length === 1 ? 'is' : 'are'} read differently by ${account.apart} of ${account.readings} readings — the freshest is shown`;
265
+ }
266
+ /**
267
+ * How wide a column may get, `null` for one nothing can stretch.
268
+ *
269
+ * Four of the eight carry a string this tool did not choose the length of — a directory
270
+ * basename, a status word tarmac does not know or the free text a `waiting` session gives, a
271
+ * model name, an effort — and one long value in any of them used to push every row of the
272
+ * table past 190 columns, on a terminal that wraps at 80. The caps are picked so that the
273
+ * worst fleet a source can hand this renderer stays within 120 CODE POINTS a row — display
274
+ * width is the wider, separate question (#80): a CJK glyph is one point and two columns, and
275
+ * the width math below counts `.length` like it always has. The page has CSS to wrap with, a
276
+ * terminal has nothing. The other four are a percentage, an age, a cost and an hour count;
277
+ * their own magnitude is what bounds them, and no cap here would ever bite.
278
+ */
279
+ // STATE is the widest of the four because it is two facts on one line: the state, and the
280
+ // reason a `waiting` session gives for being in it. `waiting · permission prompt` — the
281
+ // reason the fleet's own suite is written around — is 27 of these 28 columns.
282
+ const CAPS = [20, 28, null, null, 16, 8, null, null];
283
+ /**
284
+ * One cell, cut to its column. The ellipsis is spent out of the cap rather than added past
285
+ * it — a cap a cut cell can exceed is not a cap — and the cut is by code point, because half
286
+ * a surrogate pair is not a shorter name, it is a broken one.
287
+ */
288
+ function clip(cell, i) {
289
+ const cap = CAPS[i];
290
+ if (cap === null)
291
+ return cell;
292
+ const chars = Array.from(cell);
293
+ return chars.length <= cap ? cell : chars.slice(0, cap - 1).join('') + '…';
204
294
  }
205
295
  /**
206
296
  * The STATE column, out of the same verdict the page draws from.
@@ -330,7 +420,7 @@ export function renderLive(fleet) {
330
420
  </table></div></div>
331
421
  <div class="view view-map" role="group" aria-label="fleet map" aria-describedby="fleet-notes">${renderMap(fleet)}</div>`;
332
422
  return `<div id="limits-src" hidden>${renderLimits(fleet)}</div>
333
- <div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
423
+ <div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)}<span class="stamp"> · ${esc(new Date(health.generatedAt).toISOString())}</span></div>
334
424
  ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
335
425
  ${body}
336
426
  <div id="fleet-notes">${notes.map((n) => `<div class="note">${esc(n)}</div>`).join('')}</div>`;
@@ -349,10 +439,9 @@ ${body}
349
439
  * alone would be as old as the tab.
350
440
  */
351
441
  export function renderLimits({ rows, health }) {
352
- const account = accountLimits(rows);
353
- const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt)
354
- .map(gauge)
355
- .join('');
442
+ const account = accountLimits(rows, health.generatedAt);
443
+ const read = readLimits(account === null ? null : account.rateLimits, health.generatedAt);
444
+ const gauges = read.map(gauge).join('');
356
445
  // Dated when the snapshot behind it is past the threshold, exactly as the table dates a stale
357
446
  // context. It matters more here than anywhere else on the page: the percentage is as old as
358
447
  // that snapshot, while the countdown beside it is recomputed on every five-second re-render —
@@ -363,7 +452,15 @@ export function renderLimits({ rows, health }) {
363
452
  // fact said twice is noise. The replay has no equivalent — the ring keeps each reading and
364
453
  // never how old it was, which is why nothing replayed on this page is dated.
365
454
  const stale = account !== null && account.ageMs > health.staleAfterMs;
366
- return gauges + (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '');
455
+ // The other thing that can be wrong with this pair, and the one the age cannot say: the
456
+ // number was picked out of several readings, and they were not all about the same window.
457
+ // Beside the number rather than in a box below the fleet, because what it qualifies is the
458
+ // number — and it says the whole sentence, since a mark whose reason is elsewhere is a mark
459
+ // the reader cannot argue with.
460
+ const split = accountSplit(account, read);
461
+ return (gauges +
462
+ (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '') +
463
+ (split === null ? '' : `<span class="mixed">! ${esc(split)}</span>`));
367
464
  }
368
465
  /**
369
466
  * One window. Four things in a line: which window it is, a bar for the glance, the number that
@@ -380,7 +477,7 @@ function gauge(g) {
380
477
  : `<span class="rail" aria-hidden="true"><i style="width:${g.pct}%"></i></span>`;
381
478
  return (`<div class="gauge"><span class="lbl" aria-hidden="true">${g.label}</span><span class="sr">${g.said}</span>` +
382
479
  `${rail}<span class="num">${g.pct === null ? dash() : `${g.pct}%`}</span>` +
383
- `<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs)}</span></div>`);
480
+ `<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs, dash())}</span></div>`);
384
481
  }
385
482
  /** Which kind of missing a missing window is, in the two words both surfaces use. */
386
483
  const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
@@ -391,7 +488,7 @@ const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
391
488
  * after the reading that reported it, so the percentage beside these words belongs to a window
392
489
  * that no longer exists. Saying that is the whole point of showing a reset at all.
393
490
  */
394
- const resetWords = (ms) => ms === null ? `reset ${dash()}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
491
+ const resetWords = (ms, none) => ms === null ? `reset ${none}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
395
492
  /**
396
493
  * How long, in the two units that matter at each scale. Deliberately finer than `duration()`
397
494
  * next door, which floors a session's uptime to whole hours: five hours is a window someone
@@ -494,7 +591,10 @@ export function renderPage(fleet, view = 'table') {
494
591
  reading, the payload shapes nobody has captured yet. It reads as chrome to someone
495
592
  scanning their sessions and as an answer to someone who came looking for it. */
496
593
  .note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:.9rem 0 0; max-width:95ch; }
497
- .stale { color:var(--warn); font-weight:600; }
594
+ /* Two marks, one weight: a reading that has gone cold, and a reading picked out of several
595
+ that were not about the same window. Both say the number beside them may not be what the
596
+ reader takes it for, so neither may end up quieter than the other. */
597
+ .stale, .mixed { color:var(--warn); font-weight:600; }
498
598
  .wrap { overflow-x:auto; }
499
599
  table { border-collapse:collapse; width:100%; min-width:44rem; }
500
600
  th { text-align:left; font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em;
@@ -535,6 +635,28 @@ export function renderPage(fleet, view = 'table') {
535
635
  nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
536
636
  letter-spacing:.06em; padding:.15rem .55rem; border-radius:99px; border:1px solid transparent; }
537
637
  nav a[aria-current="page"] { color:var(--fg); border-color:var(--line); }
638
+ /* A finger is not a cursor. Every control on this page is a pill sized for a pointer that
639
+ lands on a single pixel — about 26px of box against the 44 a thumb is asked to hit — and
640
+ the fix cannot be more padding: that would redraw the page for everyone to solve a problem
641
+ only a touchscreen has. So the TAPPABLE box grows and the drawn one does not, through an
642
+ invisible overlay that exists only where the pointer is coarse.
643
+
644
+ Two rules rather than one: the inset is what is LEFT to reach 44 once the pill's own line
645
+ box and padding are counted, and the way out of a replay is set in smaller type than the
646
+ tabs. Sized as one number for all three, it came out at 41px. The border is NOT part of that
647
+ sum: the overlay's containing block is the control's padding box, so the border sits inside
648
+ the rectangle rather than adding to it. Counting it read 45.2px for a target Chrome laid out
649
+ at 43.2 — the whole feature short of the threshold it exists for, in both rules at once.
650
+
651
+ Vertical only. Every control here is already wider than 44px on its own text (the narrowest,
652
+ Map, is 50), so a horizontal inset buys nothing — and at .3rem against a .15rem gap between
653
+ the tabs it made their two overlays overlap by 7px, where a tap meant for Table landed on
654
+ Map because Map's pseudo paints later. */
655
+ nav a, .replay button, .replaying-note button { position:relative; }
656
+ @media (pointer: coarse) {
657
+ nav a::after, .replay button::after { content:''; position:absolute; inset:-.7rem 0; }
658
+ .replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }
659
+ }
538
660
  body[data-view="table"] .view-map { display:none; }
539
661
  body[data-view="map"] .view-table { display:none; }
540
662
 
@@ -585,6 +707,12 @@ export function renderPage(fleet, view = 'table') {
585
707
  beats it: unguarded, this page came up announcing a replay nobody had asked for. */
586
708
  .replay:not([hidden]) { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-top:1rem;
587
709
  padding-top:.7rem; border-top:1px solid var(--line); }
710
+ /* The pair gets a name. A button reading "Play" and a slider under a map, with nothing
711
+ saying what they move, is a control nobody dares touch — which on a phone is most of what
712
+ is on screen. Its own line above them, because dropped into the row it would read as a
713
+ label for the button rather than for the pair, and take width from the slider to do it. */
714
+ .replay .replay-name { flex-basis:100%; font-size:.7rem; font-weight:700; letter-spacing:.07em;
715
+ text-transform:uppercase; color:var(--dim); }
588
716
  .replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
589
717
  border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
590
718
  .replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
@@ -687,7 +815,7 @@ export function renderPage(fleet, view = 'table') {
687
815
  it is the only thing on the strip that has no length limit. */
688
816
  .node .prompt { flex:1; min-width:0; color:var(--dim); font-size:.76rem;
689
817
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
690
- /* Said, not shown: the three glyphs differ in silhouette, so a reader who cannot separate
818
+ /* Said, not shown: the four glyphs differ in silhouette, so a reader who cannot separate
691
819
  two hues still has the state — but a screen reader is handed a bullet and nothing else. */
692
820
  .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
693
821
  .dial { position:relative; width:5.5rem; height:5.5rem; }
@@ -756,17 +884,75 @@ export function renderPage(fleet, view = 'table') {
756
884
  .asof.stale { color:var(--warn); font-weight:600; }
757
885
  @media (max-width: 30rem) { .map.flat { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); } .map { gap:.6rem; } }
758
886
 
759
- /* Below this the table stops being a table: one card per session, every value keeping the
760
- name of the column it came from. Nothing is dropped a phone that hides the context
761
- column would be a phone that renders "not measured" as nothing at all. */
887
+ /* Below this the table stops being a table. What replaces it is the strip described down at
888
+ the tr rule: two lines per session rather than one card of eight labelled ones. Nothing is
889
+ dropped — a phone that hid the context column would be a phone that rendered "not measured"
890
+ as nothing at all — and the labels that go are the ones whose value says what it is on its
891
+ own, with the exceptions named where they are given a word back. */
762
892
  @media (max-width: 46rem) {
763
893
  body { padding:1.25rem .75rem; }
894
+ /* The summary's ISO stamp, spent. It is the widest thing on that line and the header two
895
+ lines above already says the same fact in the words a reader uses — "updated 3s ago",
896
+ counted by the shell whether or not a poll ever lands. Hidden rather than dropped: the
897
+ fragment still carries the exact second for anyone who goes looking for it. */
898
+ .meta .stamp { display:none; }
899
+ /* The handle, pinned under the thumb. The scrubber sits at the FOOT of the map and a map on
900
+ a phone is several screens tall: dragging it means the dials it moves are above the fold,
901
+ so the reader scrubs blind, lets go, scrolls up to see what changed and scrolls back. Held
902
+ at the bottom of the viewport, the hand and the thing it is changing are on screen at once.
903
+
904
+ Opaque and above what passes under it, or the dials scroll through the slider dragging
905
+ them. The negative margin gives it the page's own gutters back, so the bar reaches the
906
+ edges of the phone and the rule above it reads as an edge rather than a floating line.
907
+
908
+ The sentence under the handle stays. Hiding it for the length of a replay was the obvious
909
+ way to keep the bar short, and it silently undid a fix this file argues for forty lines
910
+ into coversText: two of its three parts are standing properties of the RECORD, not the
911
+ range — nothing replayed here is dated, and the past is drawn ungrouped — and they were
912
+ put in the reader's view precisely because they had lived "nowhere the reader can see it"
913
+ and an ungrouped map reads as a rendering that broke. A phone replaying is exactly when a
914
+ reader is staring at one. The bar is taller for it. */
915
+ body.replaying .replay:not([hidden]) { position:sticky; bottom:0; z-index:3;
916
+ background:var(--bg); border-top:1px solid var(--line);
917
+ padding:.55rem .75rem .8rem; margin:1rem -.75rem 0; }
764
918
  .wrap { overflow-x:visible; }
765
- table, tbody, tr, td { display:block; }
919
+ table, tbody { display:block; }
766
920
  table { min-width:0; }
767
921
  thead { display:none; }
768
- tr { border:1px solid var(--line); border-left-width:3px; border-radius:8px;
769
- padding:.35rem .7rem; margin-bottom:.6rem; }
922
+ /* The card stops being eight labelled lines and becomes the strip the map already speaks.
923
+ Eight lines is 234px of phone: two and a half sessions fill the screen, and "is anything
924
+ waiting on me" costs four screens of scrolling. Two lines instead — who and in what
925
+ state, then the numbers, "ctx 65% · Opus 5 · medium · $20.79 · up 15h", which is the
926
+ line a docked agent has always printed on the map next door.
927
+
928
+ Nothing is dropped. The labels that go are the ones whose values wear their own name: a
929
+ "$", "%", a model, a state that is a word. The two that do not get one back below, in
930
+ the strip's own words rather than as a column heading.
931
+
932
+ The cell steps out of the layout entirely, with display:contents, so the row is the flex
933
+ container and every VALUE is one of its items. Anything else puts a box between the row
934
+ and the thing being placed, and the order below would have nothing to order. */
935
+ tr { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.4rem; row-gap:.05rem;
936
+ border:1px solid var(--line); border-left-width:3px; border-radius:8px;
937
+ padding:.5rem .75rem .55rem; margin-bottom:.55rem; }
938
+ /* white-space on the CELL, and not on the row: the desktop rule being undone is
939
+ td { white-space:nowrap }, and an explicit declaration on the cell beats anything the row
940
+ passes down — display:contents takes the cell out of the layout, not out of the cascade.
941
+ Put on the row instead, it read correctly and left the project name 128px past the phone. */
942
+ td { display:contents; white-space:normal; }
943
+ /* The column names are gone from this width, and there is no pseudo-element hiding them for
944
+ a screen reader: both ways of trying were measured against Chrome's accessibility tree and
945
+ neither works. Out of flow (the .sr recipe) the eight labels are read as ONE block after
946
+ the whole table, detached from every value they name — worse than silence. In flow at zero
947
+ size they are pruned from the tree entirely, and they still move the strip. What a reader
948
+ hears is the strip itself: "beacon, beacon-8c, waiting · permission prompt, ctx 65%, Opus
949
+ 5, medium, $20.79, up 15h" — named for five of the eight, and unnamed for the project, the
950
+ session and the model. The desktop table names all eight in its thead, and so does every
951
+ JSON surface. Naming them here needs markup, and markup is not what this change is. */
952
+ /* The line break, as an item: zero-height, full-width, wedged between the state and the
953
+ first number. Without it the strip is a paragraph that reflows per session, and a column
954
+ of cards whose second line starts somewhere different each time cannot be scanned. */
955
+ tr::after { content:''; order:4; flex-basis:100%; height:0; }
770
956
  tr[data-state="busy"] { border-left-color:var(--busy); }
771
957
  tr[data-state="waiting"] { border-left-color:var(--wait); }
772
958
  tr[data-state="unknown"] { border-left-color:var(--warn); }
@@ -788,11 +974,74 @@ export function renderPage(fleet, view = 'table') {
788
974
  away. It spans the row instead, like the cells below it. The berth docks its own strips
789
975
  full width at every size, so what this rule is left covering is the REPLAY's flat grid. */
790
976
  .node[data-role="agent"] { grid-column:1 / -1; }
791
- td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
792
- display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
793
- td::before { content:attr(data-label); color:var(--dim); font-size:.72rem; font-weight:600;
794
- text-transform:uppercase; letter-spacing:.06em; flex:none; }
795
- .v { text-align:right; }
977
+ /* Line one: who, and in what state. The project leads and carries the weight; the session
978
+ name travels beside it in the page's grey. That order is deliberate and it is a red line
979
+ a background session is NAMED AFTER ITS PROMPT, and a prompt set as the heading of a
980
+ card is a dashboard announcing what its agents were told to do, in the largest type on
981
+ the page. It also has no length limit, so it is the one value here allowed to wrap: the
982
+ page is content-box at this width and .wrap has given up its overflow-x, so a line
983
+ that refuses to break takes the whole document sideways.
984
+
985
+ The cell above hands back the white-space the desktop table takes; these two need the
986
+ other half of it, because both can arrive as ONE long token — a project is a directory's
987
+ basename, a background session's name is a prompt — and normal has nowhere to break a
988
+ word. min-width:0 for the same reason the berths carry it: a flex item's automatic
989
+ minimum is its min-content width, which without this is the whole unbroken string. */
990
+ td[data-label="Project"] .v { order:1; font-weight:600; min-width:0; overflow-wrap:anywhere; }
991
+ td[data-label="Session"] .v { order:2; flex:1 1 0; min-width:0; color:var(--dim);
992
+ white-space:normal; overflow-wrap:anywhere; }
993
+ /* The third value that can arrive as one unbroken token, and the one the two rules above
994
+ missed: a waiting reason is FREE TEXT, so "permission prompt: /Users/…/foo.ts" is a path
995
+ with no space to break at. Wrapping inside the pill (below) breaks a sentence and does
996
+ nothing for a path — at 320px an 84-character token laid the document out at 483px, 455 of
997
+ them this cell, which is the scroll bar the fix beside it had just closed. */
998
+ td[data-label="State"] .v { order:3; min-width:0; overflow-wrap:anywhere; }
999
+ /* The reason a session is waiting is free text: "permission prompt" fits on a phone and a
1000
+ sentence does not. Held nowrap, the pill is one unbreakable item on that first line,
1001
+ which is the same scroll bar by the other road. It wraps inside its own border instead,
1002
+ and the border stops being a capsule once it has three lines to go round: 99px on a box
1003
+ that tall is an ellipse whose curve crosses the words. Under half a single line's height,
1004
+ the radius is still clamped to a capsule on one line and merely rounded on three. */
1005
+ .pill { white-space:normal; border-radius:.9rem; }
1006
+ /* Line two: the numbers, each wearing a name of its own. "65%" alone under a line of prompt
1007
+ reads as how much of the prompt is done, which is the mistake the map's strip already had
1008
+ to fix; "$20.79" and "Opus 5" say what they are without help. */
1009
+ td[data-label="Context"] .v, td[data-label="Model"] .v, td[data-label="Effort"] .v,
1010
+ td[data-label="Cost"] .v, td[data-label="Uptime"] .v { font-size:.82rem; }
1011
+ td[data-label="Context"] .v { order:5; font-variant-numeric:tabular-nums; font-weight:600; }
1012
+ td[data-label="Context"] .v::before { content:'ctx '; color:var(--dim); font-weight:400; }
1013
+ /* The weight above is for a percentage. A session with no reading renders this same cell as
1014
+ "— not chained", and in the number's weight a missing measurement reads like a
1015
+ measurement — heavier here than the same words are on the desktop table. */
1016
+ td[data-label="Context"] .v .dim { font-weight:400; }
1017
+ /* Same two rules again, and the same argument: a model and an effort are not tarmac's own
1018
+ words. They are model.display_name and the effort out of a statusline payload, copied
1019
+ through verbatim and capped nowhere — a 120-character model laid the document out at
1020
+ 814px. The three numbers below stay off this list, and NOT because of where they come
1021
+ from: a cost is copied out of that same payload and guarded even less (1e999 is legal
1022
+ JSON and reaches the cell as $Infinity, where a percentage that shape is refused). It is
1023
+ what they are PRINTED through that bounds them — a percentage clamped to 0..100 and
1024
+ floored, a duration, and a toFixed(2) that goes exponential long before it goes long.
1025
+ None can be a token wider than a phone, and a guard for that is prose that lies. */
1026
+ td[data-label="Model"] .v { order:6; min-width:0; overflow-wrap:anywhere; }
1027
+ td[data-label="Effort"] .v { order:7; color:var(--dim); min-width:0; overflow-wrap:anywhere; }
1028
+ td[data-label="Cost"] .v { order:8; font-variant-numeric:tabular-nums; }
1029
+ td[data-label="Uptime"] .v { order:9; color:var(--dim); font-variant-numeric:tabular-nums; }
1030
+ td[data-label="Model"] .v::before, td[data-label="Effort"] .v::before,
1031
+ td[data-label="Cost"] .v::before { content:'· '; color:var(--dim); font-weight:400; }
1032
+ td[data-label="Uptime"] .v::before { content:'· up '; color:var(--dim); font-weight:400; }
1033
+ /* A dash is not a value that wears its own name, and a session with no snapshot behind it
1034
+ has four of them at once: the percentage, the model, the effort and the cost all come out
1035
+ of one statusline frame, so they go missing together. That is not the corner case — it is
1036
+ every session until the status line has been chained and each one has drawn a frame, the
1037
+ state the page prints a warning about. As a strip it read "ctx — not chained · — · — · —",
1038
+ three anonymous dashes in a row, and the same happens one at a time for a session that
1039
+ reports no cost. Those three get their column word back; the other two already have one.
1040
+ The hook is the markup's own — a missing value is a .dim inside the cell's .v, and a
1041
+ present one never puts one there. */
1042
+ td[data-label="Model"] .v:has(.dim)::before { content:'· model '; }
1043
+ td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
1044
+ td[data-label="Cost"] .v:has(.dim)::before { content:'· cost '; }
796
1045
  .bar { display:none; }
797
1046
  }
798
1047
  </style>
@@ -850,6 +1099,9 @@ export function renderPage(fleet, view = 'table') {
850
1099
  <!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
851
1100
  what it says it covers is whatever the record answered with. -->
852
1101
  <div class="replay" id="replay" hidden>
1102
+ <!-- "Replay", and nothing about how much of the day it holds: the range is the record's to
1103
+ state, in the sentence below, which is built around never calling ten minutes a day. -->
1104
+ <span class="replay-name">Replay</span>
853
1105
  <button type="button" id="play">Play</button>
854
1106
  <input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
855
1107
  <div class="covers" id="covers"></div>
@@ -877,7 +1129,7 @@ export function renderPage(fleet, view = 'table') {
877
1129
  * The replay below is the one exception, and it is one the issue asks for: scrubbing a day
878
1130
  * has to be a lookup in samples the page already holds, or every pixel of a drag would be a
879
1131
  * request and a `claude agents --json` behind it. So a second, smaller renderer lives in the
880
- * browser — fed the same three words, the same three glyphs and the same dial geometry as the
1132
+ * browser — fed the same three words, the same four glyphs and the same dial geometry as the
881
1133
  * server's, by interpolation rather than by copy, and executed by `test/replay-script`.
882
1134
  */
883
1135
  export const REFRESH_MS = 5000;
@@ -912,6 +1164,22 @@ function pageScript(view) {
912
1164
  var off = document.getElementById('offline'), why = document.getElementById('why');
913
1165
  var limits = document.getElementById('limits');
914
1166
  var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
1167
+ // How many polls in a row have come back with nothing usable, and when the last of them was.
1168
+ // On a phone the page is read on a radio, and one dropped request is a tunnel rather than an
1169
+ // outage — the banner frames the table off and says the fleet cannot be read, which is the
1170
+ // wrong thing to shout five seconds before the next answer lands. It waits for the second
1171
+ // consecutive miss; the age upstairs keeps counting meanwhile, so nothing on the page is
1172
+ // claiming to be fresher than it is.
1173
+ //
1174
+ // Consecutive means in a row IN TIME, which is why the stamp is here. A count cleared only by
1175
+ // a successful poll is not the same rule: a hidden tab issues no polls, so a miss from before
1176
+ // the reader locked their phone sat there for an hour, and the wake-up poll — the likeliest
1177
+ // miss of the session, fired while the radio is still reassociating — found it and raised the
1178
+ // banner over one dropped request. A miss further back than a few poll intervals starts the
1179
+ // count again. The window is bounded at both ends and neither end is arbitrary: below one
1180
+ // poll interval two real misses in a row would never meet, and above five a locked phone
1181
+ // comes back to a miss from minutes ago being called consecutive with this one.
1182
+ var misses = 0, missAt = 0, MISSES_BEFORE_BANNER = 2, MISS_WINDOW_MS = 3 * ${REFRESH_MS};
915
1183
 
916
1184
  function ago(ms) {
917
1185
  // A clock that steps backwards (an NTP correction, a laptop waking) must not produce
@@ -922,8 +1190,18 @@ function pageScript(view) {
922
1190
  return m < 60 ? m + 'm' : Math.round(m / 60) + 'h';
923
1191
  }
924
1192
 
1193
+ // Called for the one failure that is NOT a missed poll: a request the server accepted and
1194
+ // never answered. Twenty seconds of silence from a live connection is not a dropped packet,
1195
+ // so it says so at once, without the second miss the count is there to wait for. It does not
1196
+ // touch the count: a miss cannot take this banner back down, because a miss never assigns the
1197
+ // failing flag anything but true, and only an ANSWER puts it back to false.
925
1198
  function fail(why_) {
926
1199
  failing = true;
1200
+ // Retired, not merely dropped. Clearing the in-flight flag without moving the generation
1201
+ // left the abandoned request still ours, so the answer that arrived twenty seconds later was
1202
+ // swapped in and stamped "updated 0s ago" — the freshest label on the page over a fleet read
1203
+ // before the stall was declared. The manual has always said such an answer is discarded.
1204
+ gen += 1;
927
1205
  why.textContent = why_;
928
1206
  off.hidden = false;
929
1207
  document.body.classList.toggle('failing', true);
@@ -983,10 +1261,20 @@ function pageScript(view) {
983
1261
  if (src) limits.innerHTML = src.innerHTML;
984
1262
  last = Date.now();
985
1263
  failing = false;
1264
+ // Consecutive, not cumulative: two blips an hour apart are two blips, and a count that
1265
+ // never went back to zero would turn the second one into a permanent banner.
1266
+ misses = 0;
986
1267
  });
987
1268
  }).catch(function (e) {
988
1269
  if (!mineStill()) return;
989
- failing = true;
1270
+ if (Date.now() - missAt > MISS_WINDOW_MS) misses = 0;
1271
+ misses += 1;
1272
+ missAt = Date.now();
1273
+ // Raised here, never lowered here. Only an ANSWER says the server came back, so this
1274
+ // assigns true or nothing at all — derived both ways, the window that starts a fresh count
1275
+ // also cleared the alarm, and a reader who locked their phone for ten minutes while the
1276
+ // server was down unlocked onto a green page over a fleet nobody could read.
1277
+ if (misses >= MISSES_BEFORE_BANNER) failing = true;
990
1278
  why.textContent = String((e && e.message) || e).slice(0, 200);
991
1279
  }).then(function () {
992
1280
  // Not ours to unlock: a request we were given up on must not clear a flag that a newer
@@ -1014,7 +1302,7 @@ function pageScript(view) {
1014
1302
  var record = null, recordAt = 0, at = -1, replaying = false, playing = null, hgen = 0;
1015
1303
 
1016
1304
  // The vocabulary and the geometry, handed over rather than written twice: three words for
1017
- // the three kinds of missing, three glyphs for the three states, one dial radius.
1305
+ // the three kinds of missing, four glyphs for the four states, one dial radius.
1018
1306
  var WHY = ${JSON.stringify(CTX_WHY)}, SHAPE = ${JSON.stringify(SHAPE)};
1019
1307
  var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
1020
1308
  var ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
@@ -1379,11 +1667,13 @@ function pageScript(view) {
1379
1667
  function renderRow(r) {
1380
1668
  const state = stateOf(r);
1381
1669
  const word = stateLabel(state, r);
1382
- // `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
1383
- // and a value whose column has no name is a bare "—" that could mean anything.
1384
- // Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
1385
- // value right, and two sibling nodes in one cell get pushed to opposite ends of the card
1386
- // which is how "63%" once ended up stranded in the middle of a row, under the wrong label.
1670
+ // `data-label` is not decoration, and no longer only for the reason it was: the header row is
1671
+ // gone below ~46rem, and what the attribute does there is CARRY THE STRIP. The per-column
1672
+ // rules select `td[data-label="…"] .v` for their `order`, for their wrapping, and for the five
1673
+ // `::before` prefixes that are the only column words left at that width `ctx `, up `, and
1674
+ // model `/`· effort `/`· cost ` for a value that is a bare dash. One element per cell, since
1675
+ // `td` is `display:contents` there: the `.v` IS the row's flex item, and a second sibling in
1676
+ // one cell would be a second item placed on an `order` of its own.
1387
1677
  return `<tr data-state="${state}">
1388
1678
  <td data-label="Project" class="project"><span class="v">${esc(r.project)}</span></td>
1389
1679
  <td data-label="Session" class="dim"><span class="v">${esc(r.name)}</span></td>
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // so a read-only snapshot directory (the fleet's own, for the demo) is just a parameter.
5
5
  import http from 'node:http';
6
6
  import { reason, renderLive, renderPage } from './render.js';
7
- import { SOURCE_PHRASE } from './config.js';
7
+ import { hostName, SOURCE_PHRASE } from './config.js';
8
8
  import { createHistory, HISTORY_CADENCE_MS } from './history.js';
9
9
  /**
10
10
  * On every answer, including the refusals and the 500s. The page swaps what this port returns
@@ -20,7 +20,23 @@ const PAGES = new Map([
20
20
  ['/', 'table'],
21
21
  ['/map', 'map'],
22
22
  ]);
23
- export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS }) {
23
+ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [] }) {
24
+ // Normalised HERE rather than trusted to arrive that way. This is the last thing between a
25
+ // foreign origin and the fleet, so it owns both sides of its own comparison — the config
26
+ // parser cuts a name the same way, and neither leans on the other having done it.
27
+ //
28
+ // The empty one is dropped for the same reason, and it is not tidiness: a name that
29
+ // normalises to nothing is a name that every Host normalising to nothing matches — `:8443`,
30
+ // a lone bracket — which would be a guard standing open on a list that looks set. Nothing
31
+ // reachable from a flag, a variable or a file gets here empty; that is the parser's promise,
32
+ // and this is the guard not resting on it.
33
+ const trusted = new Set(trustedHosts.map((h) => hostName(h.trim()).toLowerCase()).filter((h) => h !== ''));
34
+ // Which rule refused, decided once. With hosts named, "loopback hosts only" would read as a
35
+ // flag that never took; with none, this is the sentence it has always been, to the byte. The
36
+ // Host itself is never quoted back: it is the one string on the request the caller wrote.
37
+ const refusal = trusted.size === 0
38
+ ? 'tarmac serves loopback hosts only\n'
39
+ : 'tarmac serves loopback and trusted hosts only\n';
24
40
  // What this serve has already read, kept for a day and never written down. `since` is the
25
41
  // moment this server was made, not the first sample that landed: the span it covers is how
26
42
  // long the process has been up, and an hour of it with nothing in it is a fact worth
@@ -51,10 +67,12 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS
51
67
  };
52
68
  const server = http.createServer(async (req, res) => {
53
69
  // Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
54
- // from reading /api/fleet — which carries cwd paths, session ids and costs.
55
- if (!isLoopbackHost(req.headers.host)) {
70
+ // from reading /api/fleet — which carries cwd paths, session ids and costs. Whatever a
71
+ // reader trusted on top of that is a name, exactly: matched whole, never as a prefix, a
72
+ // suffix or a pattern, so trusting one host can never be trusting a family of them.
73
+ if (!isLoopbackHost(req.headers.host) && !isTrustedHost(req.headers.host, trusted)) {
56
74
  res.writeHead(403, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
57
- res.end('tarmac serves loopback hosts only\n');
75
+ res.end(refusal);
58
76
  return;
59
77
  }
60
78
  // The Host check stops another origin READING this port; it does not stop one poking it.
@@ -226,6 +244,30 @@ function attempt(server, port, host) {
226
244
  function isLoopbackHost(host) {
227
245
  if (!host)
228
246
  return false;
229
- const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
247
+ const name = hostName(host);
230
248
  return name === 'localhost' || name === '127.0.0.1' || name === '::1';
231
249
  }
250
+ /**
251
+ * One of the names the reader wrote down, matched whole — never as a prefix, a suffix or a
252
+ * pattern, and no wildcard is accepted into that list or honoured against a request.
253
+ *
254
+ * What is matched is the name `hostName` cuts out, and that cut is the loopback check's, kept
255
+ * shared rather than tightened: it drops the port, and it drops a leading `[` or a trailing
256
+ * `]` whether or not they pair. So `[name` and `name]` reach the comparison as `name`, exactly
257
+ * as `[localhost` has always reached it as `localhost`. Nothing is opened by it — `Host` is a
258
+ * forbidden header, a browser derives it from the URL, and a client free to type the header is
259
+ * free to type the name itself — and narrowing it here would change what the default answers.
260
+ *
261
+ * The port is not part of the name on either side: a proxy presents `name:8443` on one setup
262
+ * and a bare `name` on 443, and the port in a `Host` header is chosen by whoever sends it, so
263
+ * matching on it would have refused half the setups this exists for and barred nobody. Case is
264
+ * not part of it either — host names are case-insensitive, and the loopback names above are
265
+ * left exactly as strict as they have always been rather than loosened to match.
266
+ *
267
+ * `host` is whichever `Host` node reports; it reports the first when a request carries two.
268
+ */
269
+ function isTrustedHost(host, trusted) {
270
+ if (!host || trusted.size === 0)
271
+ return false;
272
+ return trusted.has(hostName(host).toLowerCase());
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",