@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 +78 -70
- package/dist/args.js +11 -3
- package/dist/cli.js +11 -6
- package/dist/config.js +61 -1
- package/dist/fleet.js +70 -11
- package/dist/history.js +1 -1
- package/dist/limits.js +77 -7
- package/dist/render.js +321 -31
- package/dist/server.js +48 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|

|
|
7
7
|

|
|
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
|
|
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.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
62
|
-
as coming from another origin
|
|
63
|
-
sends no such mark
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
75
|
-
the same reading
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
86
|
-
serve has seen
|
|
87
|
-
the
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
The
|
|
93
|
-
thin, amber and dated
|
|
94
|
-
at zero
|
|
95
|
-
snapshot for
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
[the manual](docs/MANUAL.md#installing-safely
|
|
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
|
-
|
|
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
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
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
|
|
164
|
-
record
|
|
165
|
-
|
|
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
|
|
47
|
-
environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR)
|
|
48
|
-
<home>/.claude/tarmac/config.json ({"staleAfterMs": …, "port": …,
|
|
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
|
-
|
|
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
|
|
160
|
-
* the youngest is the one still true. Same rule as everything else in this module:
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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 =
|
|
45
|
-
const has = w !==
|
|
46
|
-
const v =
|
|
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
|
|
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
|
|
354
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
|
760
|
-
|
|
761
|
-
column would be a phone that
|
|
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
|
|
919
|
+
table, tbody { display:block; }
|
|
766
920
|
table { min-width:0; }
|
|
767
921
|
thead { display:none; }
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
@@ -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
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
+
}
|