@adrrr/tarmac 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -63
- 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/map.js +37 -30
- package/dist/render.js +533 -63
- 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 with the scrubber dragged
|
|
16
|
+
alt="The tarmac map, live and then replayed. It opens on the live fleet, whose nodes are grouped into a frame per working directory, each labelled with its project. As the scrubber is dragged the frames give way to the flat record: sessions drawn as dials, each arc a context window, background agents as strips of text, with the account's five-hour and seven-day gauges above them. Sessions appear and disappear, the arcs fill and reset, the five-hour window fills and rolls over, and a banner names the minute being replayed until Back to live is pressed and the frames come back.">
|
|
17
17
|
</picture>
|
|
18
18
|
|
|
19
19
|
[Quickstart](#quickstart) · [The map](#the-map) · [Install](#install) ·
|
|
@@ -42,112 +42,135 @@ atlas idle — fresh 8h ! Opus 5 high $0.00 8h
|
|
|
42
42
|
! 3 reading(s) marked "!" are older than 10m (--stale-after)
|
|
43
43
|
|
|
44
44
|
4 sessions · 2 busy · $109.35
|
|
45
|
+
account 5h 17% resets in 2h 14m · 7d 42% resets in 3d 11h · as of 7m
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
That is the table with the status line chained.
|
|
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
|
-
|
|
63
|
-
|
|
66
|
+
binds to loopback and refuses any request whose `Host` is not loopback, or that a browser
|
|
67
|
+
marks as coming from another origin, so your cwd paths and costs never leave the machine. A
|
|
68
|
+
client that sends no such mark, curl or a script, is left alone. Behind a reverse proxy, which
|
|
69
|
+
presents a `Host` of its own, `--trust-host <name>` names the one to let through and says who
|
|
70
|
+
that lets in: [the manual](docs/MANUAL.md#putting-it-behind-a-reverse-proxy). The listening
|
|
71
|
+
rules: [the manual](docs/MANUAL.md#what-serve-listens-on).
|
|
64
72
|
|
|
65
73
|
<picture>
|
|
66
74
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
|
|
67
75
|
<img src="docs/media/map-light.png" width="1100"
|
|
68
|
-
alt="The tarmac map, live.
|
|
76
|
+
alt="The tarmac map, live. Four framed groups, one per working directory and labelled with its project: beacon, holding a session halted on a permission prompt; harbor, holding a session busy with its context arc at 90% and a background agent docked under the cards of that frame as a strip named after its prompt; quay, reporting a status tarmac does not know; and atlas, idle at 36%. A frame says only that its nodes were read in the same directory. Nothing inside one claims that any node dispatched another. The account's five-hour and seven-day gauges sit above them, and one warning above the fleet names the unrecognised status rather than filing it as idle.">
|
|
69
77
|
</picture>
|
|
70
78
|
|
|
71
|
-
The tab in the header swaps the table for the same fleet as nodes
|
|
72
|
-
its context, the shape by the name its state.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
the
|
|
84
|
-
[
|
|
79
|
+
The tab in the header swaps the table for the same fleet as nodes: one per session, the arc
|
|
80
|
+
its context, the shape by the name its state. A background agent has no terminal behind it to
|
|
81
|
+
draw a frame with, so it gets a strip of text rather than a dial that could never fill. Both
|
|
82
|
+
views render the same reading into the same fragment, so they can never disagree.
|
|
83
|
+
|
|
84
|
+
The nodes are grouped by working directory: a frame per directory, labelled with its project,
|
|
85
|
+
the sessions inside it as cards and the agents docked underneath as strips. That is the whole
|
|
86
|
+
of what the frame claims: these were read in the same directory. It is not a parentage.
|
|
87
|
+
`claude agents --json` publishes nothing that ties an agent to whoever dispatched it, so no
|
|
88
|
+
label, no position and no line inside a frame says one node asked for another. An agent whose
|
|
89
|
+
directory matches no session gets a frame of its own rather than somebody else's.
|
|
90
|
+
|
|
91
|
+
Above the fleet are the account's five-hour and seven-day
|
|
92
|
+
[gauges](docs/MANUAL.md#the-accounts-two-windows); under it a scrubber over the day this
|
|
93
|
+
serve has seen. Drag it and the nodes render the fleet as it was at that minute, press play
|
|
94
|
+
and the day walks past. Replayed, they are drawn flat and unframed: the record keeps a project
|
|
95
|
+
name and never the directory it was read in, and a basename is not a directory. Two checkouts
|
|
96
|
+
of `atlas` answer to the same word, so a frame drawn on it would claim a shared directory
|
|
97
|
+
nobody can check.
|
|
98
|
+
|
|
99
|
+
The map follows the rules the table follows. A reading past the freshness threshold is drawn
|
|
100
|
+
thin, amber and dated. A percentage nobody measured is an empty dotted dial rather than a ring
|
|
101
|
+
at zero. Where nothing was published at all, such as the strip of an agent the join found no
|
|
102
|
+
snapshot for, nothing is drawn in its place, neither a dial nor a dash. A word
|
|
103
|
+
`claude agents --json` printed that tarmac has no boolean for is shown as it came rather than
|
|
104
|
+
filed as `idle`, and raises one warning above the fleet naming it. The page is JSON
|
|
105
|
+
underneath: `GET /api/fleet` and `GET /api/history` serve exactly what the map draws. Details
|
|
106
|
+
in [the manual](docs/MANUAL.md#the-map).
|
|
85
107
|
|
|
86
108
|
## Install
|
|
87
109
|
|
|
88
110
|
`install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
|
|
89
|
-
say-so alone. It prints the whole plan first
|
|
90
|
-
|
|
111
|
+
say-so alone. It prints the whole plan first, including the exact command that undoes it,
|
|
112
|
+
then waits for a typed word. `y` is not an answer; scripts pass `--yes`, deliberately.
|
|
91
113
|
|
|
92
|
-
A status line you already had is
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
[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).
|
|
99
121
|
|
|
100
122
|
## Why it does not break
|
|
101
123
|
|
|
102
124
|
The usual ways to watch a Claude Code fleet read something Claude Code never promised would
|
|
103
|
-
stay put: transcript files, terminal panes, undocumented paths. Those break on an update,
|
|
104
|
-
|
|
125
|
+
stay put: transcript files, terminal panes, undocumented paths. Those break on an update, and
|
|
126
|
+
worse, they break *quietly*, reporting a calm empty fleet.
|
|
105
127
|
|
|
106
128
|
tarmac reads two things instead:
|
|
107
129
|
|
|
108
130
|
| Source | What it gives | How solid |
|
|
109
131
|
|---|---|---|
|
|
110
132
|
| `claude agents --json` | which sessions exist, busy, idle or waiting on you, cwd, uptime | a documented CLI surface (`--help`: *"Print active sessions … as a JSON array … for scripting"*) |
|
|
111
|
-
| the status line payload | context %, model, effort, cost | the JSON Claude Code hands to your own `statusLine.command` on every frame
|
|
133
|
+
| the status line payload | context %, model, effort, cost | the JSON Claude Code hands to your own `statusLine.command` on every frame. Observed, not published as a schema |
|
|
112
134
|
|
|
113
|
-
That second line is the honest caveat
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
[the manual](docs/MANUAL.md#degradation-state-by-state).
|
|
135
|
+
That second line is the honest caveat. The real defence is not immunity, it is visible
|
|
136
|
+
degradation. A missing measurement is never a confident `0`. It is an em dash that names which
|
|
137
|
+
kind of missing it is: `absent` for a session no status line ever wrote for, `fresh` for one
|
|
138
|
+
that has taken no turn yet, `drift` for a release that moved the payload out from under us. A
|
|
139
|
+
stale reading keeps its value and gets dated with a `!`. A Claude Code build no fixture covers
|
|
140
|
+
gets its version named *before* anything breaks, and tarmac keeps reporting. The full
|
|
141
|
+
state-by-state table is in [the manual](docs/MANUAL.md#degradation-state-by-state).
|
|
121
142
|
|
|
122
143
|
## Configuration
|
|
123
144
|
|
|
124
|
-
Three numbers are opinions
|
|
125
|
-
|
|
145
|
+
Three numbers are opinions rather than truths, so all three are yours, and behind a reverse
|
|
146
|
+
proxy the hosts `serve` answers to are yours as well. Everything else is deliberately not
|
|
147
|
+
configurable, and all of it works with no configuration at all.
|
|
126
148
|
|
|
127
149
|
| Setting | Flag | Default |
|
|
128
150
|
|---|---|---|
|
|
129
151
|
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
|
|
130
152
|
| port | `--port 8080` | `4477` |
|
|
131
|
-
| snapshots dir (read side) | `--snapshots-dir DIR` | the
|
|
153
|
+
| snapshots dir (read side) | `--snapshots-dir DIR` | the wrapper's frozen path when installed, else the XDG state directory |
|
|
154
|
+
| trusted hosts | `--trust-host HOST`, once per host | none — loopback only |
|
|
132
155
|
|
|
133
156
|
Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
157
|
+
Flag beats environment beats config file beats default, settled per setting; `serve` opens by
|
|
158
|
+
printing each effective value and where it came from. Nothing is silently dropped or silently
|
|
159
|
+
corrected. A value that will not parse stops the run and says what it got, where it came from,
|
|
160
|
+
and what would have worked. Spellings, edge cases and the two health fields
|
|
161
|
+
`tarmac list --json` reports: [the manual](docs/MANUAL.md#configuration).
|
|
139
162
|
|
|
140
163
|
## What it deliberately does not do
|
|
141
164
|
|
|
142
|
-
- **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason
|
|
143
|
-
a permission prompt
|
|
144
|
-
What it will not do is guess at the rest: a session that asked you a question in prose
|
|
165
|
+
- **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason, such as
|
|
166
|
+
a permission prompt or an open dialog, and tarmac draws exactly that, in the table and on
|
|
167
|
+
the map. What it will not do is guess at the rest: a session that asked you a question in prose
|
|
145
168
|
still reports `idle`, and the only way to know better is to read a transcript, which is
|
|
146
|
-
the one thing this tool will not do. The signal is as good as
|
|
169
|
+
the one thing this tool will not do. The signal is as good as its source, and no better.
|
|
147
170
|
- **No history on disk.** `tarmac list` is a snapshot in time. A running `serve` holds the
|
|
148
|
-
last 24 hours of the readings it took itself, in memory, so the page can replay them
|
|
149
|
-
record
|
|
150
|
-
|
|
171
|
+
last 24 hours of the readings it took itself, in memory, so the page can replay them. That
|
|
172
|
+
record reaches no further back than the serve that took it, and goes when it goes. None of
|
|
173
|
+
it is written down.
|
|
151
174
|
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
152
175
|
- **No remote fleets.** It watches the machine it runs on.
|
|
153
176
|
|
package/dist/args.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Unknown options and unknown commands are ERRORS. A typo silently ignored is how someone
|
|
5
5
|
// ends up believing they pointed tarmac at a directory it never read.
|
|
6
|
-
import { parsePort } from './config.js';
|
|
6
|
+
import { parsePort, parseTrustHost } from './config.js';
|
|
7
7
|
const COMMANDS = new Set(['list', 'serve', 'install', 'uninstall', 'help']);
|
|
8
8
|
// `| undefined` is load-bearing, not decoration: without it the compiler types the lookup
|
|
9
9
|
// below as always-present and the `if (!key) throw` guard reads as dead code — which is how
|
|
@@ -12,6 +12,7 @@ const OPTIONS = {
|
|
|
12
12
|
'--port': 'port',
|
|
13
13
|
'--stale-after': 'staleAfter',
|
|
14
14
|
'--snapshots-dir': 'snapshotsDir',
|
|
15
|
+
'--trust-host': 'trustHost',
|
|
15
16
|
'--home': 'home',
|
|
16
17
|
'--claude-bin': 'claudeBin',
|
|
17
18
|
'--json': 'json',
|
|
@@ -20,6 +21,8 @@ const OPTIONS = {
|
|
|
20
21
|
'--help': 'help',
|
|
21
22
|
};
|
|
22
23
|
const FLAGS = new Set(['json', 'watch', 'yes', 'help']);
|
|
24
|
+
/** Options that ACCUMULATE rather than overwrite — passed twice, both values are kept. */
|
|
25
|
+
const LISTS = new Set(['trustHost']);
|
|
23
26
|
/**
|
|
24
27
|
* Which options each command really reads. The doc-comment above refuses a flag nobody
|
|
25
28
|
* implements; this refuses a flag THIS command does not implement, which is the same defect
|
|
@@ -30,7 +33,7 @@ const FLAGS = new Set(['json', 'watch', 'yes', 'help']);
|
|
|
30
33
|
*/
|
|
31
34
|
const ACCEPTS = {
|
|
32
35
|
list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help']),
|
|
33
|
-
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'help']),
|
|
36
|
+
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'home', 'claudeBin', 'help']),
|
|
34
37
|
install: new Set(['home', 'yes', 'help']),
|
|
35
38
|
uninstall: new Set(['home', 'yes', 'help']),
|
|
36
39
|
help: new Set(['help']),
|
|
@@ -51,7 +54,7 @@ export function accepts(command, flag) {
|
|
|
51
54
|
return key !== undefined && ACCEPTS[command].has(key);
|
|
52
55
|
}
|
|
53
56
|
export function parseArgs(argv) {
|
|
54
|
-
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false };
|
|
57
|
+
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false };
|
|
55
58
|
let i = 0;
|
|
56
59
|
if (argv[0] && !argv[0].startsWith('-')) {
|
|
57
60
|
if (!COMMANDS.has(argv[0]))
|
|
@@ -81,6 +84,11 @@ export function parseArgs(argv) {
|
|
|
81
84
|
// in the same words wherever it was set.
|
|
82
85
|
out.port = parsePort(value, '--port');
|
|
83
86
|
}
|
|
87
|
+
else if (LISTS.has(key)) {
|
|
88
|
+
// Same rule, for the same reason — and the value is kept as the guard will compare it,
|
|
89
|
+
// so what `serve` prints on startup is what it will actually match against.
|
|
90
|
+
out[key].push(parseTrustHost(value, flag));
|
|
91
|
+
}
|
|
84
92
|
else {
|
|
85
93
|
out[key] = value;
|
|
86
94
|
}
|
package/dist/cli.js
CHANGED
|
@@ -24,7 +24,7 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
24
24
|
[--claude-bin PATH] [--json] [--watch]
|
|
25
25
|
one-shot fleet table — with --watch, redrawn every 5s until ^C
|
|
26
26
|
tarmac serve [--home DIR] [--port N] [--stale-after D] [--snapshots-dir DIR]
|
|
27
|
-
[--claude-bin PATH]
|
|
27
|
+
[--claude-bin PATH] [--trust-host HOST]
|
|
28
28
|
local dashboard
|
|
29
29
|
tarmac install [--home DIR] [--yes]
|
|
30
30
|
chain the statusline
|
|
@@ -42,11 +42,15 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
42
42
|
(default: $XDG_STATE_HOME/tarmac/snapshots, or
|
|
43
43
|
<home>/.local/state/tarmac/snapshots)
|
|
44
44
|
--claude-bin path to the claude CLI (default: claude)
|
|
45
|
+
--trust-host a Host the dashboard also answers to, besides loopback — repeat it
|
|
46
|
+
once per host (default: none). For a reverse proxy: give the name
|
|
47
|
+
your browser shows, without the port, and remember that whoever can
|
|
48
|
+
reach that name can read this fleet
|
|
45
49
|
|
|
46
|
-
Those
|
|
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;
|