@adrrr/tarmac 0.3.0 → 0.4.1
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 +87 -116
- package/dist/fleet.js +100 -3
- package/dist/history.js +88 -0
- package/dist/limits.js +62 -0
- package/dist/map.js +10 -1
- package/dist/render.js +655 -25
- package/dist/schema.js +2 -2
- package/dist/server.js +74 -3
- package/dist/sessions.js +15 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,6 +10,26 @@
|
|
|
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
|
+
<picture>
|
|
14
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/media/replay-dark.gif">
|
|
15
|
+
<img src="docs/media/replay-light.gif" width="1100"
|
|
16
|
+
alt="The tarmac map with the scrubber dragged across the record: five sessions drawn as dials, each arc a context window, the account's five-hour and seven-day gauges above them. As the handle moves, sessions appear and disappear, the arcs fill and reset, the five-hour window fills and rolls over, and a banner names the minute being replayed until Back to live is pressed.">
|
|
17
|
+
</picture>
|
|
18
|
+
|
|
19
|
+
[Quickstart](#quickstart) · [The map](#the-map) · [Install](#install) ·
|
|
20
|
+
[Configuration](#configuration) · [Commands](docs/MANUAL.md#commands-and-options) ·
|
|
21
|
+
[Manual](docs/MANUAL.md) · [Changelog](CHANGELOG.md) · [Issues](https://github.com/adrrr/tarmac/issues)
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @adrrr/tarmac # one-shot fleet table
|
|
27
|
+
npx @adrrr/tarmac --watch # the same table, redrawn every 5s until ^C
|
|
28
|
+
npx @adrrr/tarmac serve # the same fleet in the browser
|
|
29
|
+
npx @adrrr/tarmac install # chain the status line: unlocks ctx, model, effort and cost
|
|
30
|
+
npx @adrrr/tarmac uninstall # hand your status line back
|
|
31
|
+
```
|
|
32
|
+
|
|
13
33
|
```
|
|
14
34
|
$ npx @adrrr/tarmac
|
|
15
35
|
|
|
@@ -30,76 +50,52 @@ same command still lists every session, its state and its uptime, straight from
|
|
|
30
50
|
`—`, and the line under the table counts how many sessions are covered.
|
|
31
51
|
|
|
32
52
|
Node ≥ 20. **Zero runtime dependencies** — no framework, no bundler, nothing to audit.
|
|
53
|
+
`--help` works everywhere, and an option handed to a command that does not read it is an
|
|
54
|
+
**error**, not something quietly ignored. Every command, flag and route:
|
|
55
|
+
[the manual](docs/MANUAL.md#commands-and-options).
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
## Install
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
npx @adrrr/tarmac # one-shot fleet table
|
|
40
|
-
npx @adrrr/tarmac --watch # the same table, redrawn every 5s until ^C
|
|
41
|
-
npx @adrrr/tarmac serve # the same fleet in the browser
|
|
42
|
-
npx @adrrr/tarmac install # chain the status line: unlocks ctx, model, effort and cost
|
|
43
|
-
npx @adrrr/tarmac uninstall # hand your status line back
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
`install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
|
|
47
|
-
say-so alone. It prints the whole plan first — including the exact command that undoes it —
|
|
48
|
-
and waits for a **typed word** (`y` is not an answer; scripts pass `--yes`, deliberately):
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
tarmac install — your home
|
|
52
|
-
|
|
53
|
-
file /Users/jane/.claude/settings.json
|
|
54
|
-
statusLine now ~/bin/my-line.sh
|
|
55
|
-
statusLine next /Users/jane/.claude/tarmac/statusline.sh
|
|
56
|
-
↳ which calls ~/bin/my-line.sh (your display is unchanged)
|
|
57
|
-
snapshots /Users/jane/.local/state/tarmac/snapshots
|
|
58
|
-
undo tarmac uninstall
|
|
59
|
-
|
|
60
|
-
Type "install" to proceed, anything else to abort:
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
A status line you already had is **wrapped, not replaced**: its display stays byte-identical,
|
|
64
|
-
and `uninstall` names which of its four restore modes ran — `bytes`, the usual one, puts the
|
|
65
|
-
original file back exactly. Note that the write re-serialises `settings.json`, so a
|
|
66
|
-
version-controlled one shows a formatting diff, not a one-line diff.
|
|
67
|
-
|
|
68
|
-
The only things that land under `~/.claude/` are that wrapper and the `backup.json` that
|
|
69
|
-
undoes it, neither of which changes at runtime: the snapshots the wrapper writes at every
|
|
70
|
-
frame go to `$XDG_STATE_HOME/tarmac/snapshots` (`~/.local/state/tarmac/snapshots` by
|
|
71
|
-
default), because `~/.claude` is a directory people commit. Coming from 0.1.x, `install`
|
|
72
|
-
clears the payloads an older version left in there, says how many and from where, and — when
|
|
73
|
-
`~/.claude` is a git repository — prints the `.gitignore` line worth adding.
|
|
74
|
-
|
|
75
|
-
## The dashboard
|
|
57
|
+
## The map
|
|
76
58
|
|
|
77
59
|
`tarmac serve` puts the same fleet in the browser — every session a row, ages that keep
|
|
78
60
|
climbing, and a banner the moment a refresh fails instead of a table quietly going stale. It
|
|
79
|
-
|
|
61
|
+
binds to loopback and refuses any request whose `Host` is not loopback, or that a browser does
|
|
62
|
+
not mark same-origin: your cwd paths and costs never leave the machine. The listening rules:
|
|
63
|
+
[the manual](docs/MANUAL.md#what-serve-listens-on).
|
|
80
64
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
does not mark same-origin — your cwd paths and costs never leave the machine. A busy
|
|
87
|
-
**default** port walks up to the next free one and says so; a port you chose yourself — flag,
|
|
88
|
-
environment or config file — refuses instead, because you chose it.
|
|
89
|
-
|
|
90
|
-
### The map
|
|
65
|
+
<picture>
|
|
66
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
|
|
67
|
+
<img src="docs/media/map-light.png" width="1100"
|
|
68
|
+
alt="The tarmac map, live. Five nodes: beacon halted on a permission prompt, harbor busy with its context arc at 90%, a background agent named after its prompt beside it, quay reporting a status tarmac does not know, and atlas idle at 36%. The account's five-hour and seven-day gauges sit above them, and one warning above the fleet names the unrecognised status rather than filing it as idle.">
|
|
69
|
+
</picture>
|
|
91
70
|
|
|
92
71
|
The tab in the header swaps the table for the same fleet as nodes — one per session, the arc
|
|
93
|
-
its context, the shape by the name its state
|
|
94
|
-
|
|
72
|
+
its context, the shape by the name its state. It is the same reading in the same fragment, so
|
|
73
|
+
the two views can never disagree. Above the fleet are the account's five-hour and seven-day
|
|
74
|
+
[gauges](docs/MANUAL.md#the-accounts-two-windows); under it a **scrubber over the day this
|
|
75
|
+
serve has seen** — drag it and the dials render
|
|
76
|
+
the fleet as it was at that minute, press play and the day walks past.
|
|
95
77
|
|
|
96
78
|
The rules the table follows, the map follows: a reading past the freshness threshold is drawn
|
|
97
|
-
thin, amber and dated
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
79
|
+
thin, amber and dated, a percentage nobody measured is an empty dotted dial rather than a ring
|
|
80
|
+
at zero, and a word `claude agents --json` printed that tarmac has no boolean for is shown as
|
|
81
|
+
it came rather than quietly filed as `idle` — and raises one warning above the fleet naming
|
|
82
|
+
it. The page is JSON underneath: `GET /api/fleet` and `GET /api/history` serve exactly what
|
|
83
|
+
the map draws. Details in
|
|
84
|
+
[the manual](docs/MANUAL.md#the-map).
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
`install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
|
|
89
|
+
say-so alone. It prints the whole plan first — including the exact command that undoes it —
|
|
90
|
+
and waits for a **typed word** (`y` is not an answer; scripts pass `--yes`, deliberately).
|
|
91
|
+
|
|
92
|
+
A status line you already had is **wrapped, not replaced**: its display stays byte-identical,
|
|
93
|
+
and `uninstall` names which of its four restore modes ran. Two files land under `~/.claude/` —
|
|
94
|
+
the wrapper and the `backup.json` that undoes it — and neither changes at runtime: the
|
|
95
|
+
snapshots go to `~/.local/state/tarmac/snapshots` (`$XDG_STATE_HOME` when set), because
|
|
96
|
+
`~/.claude` is a directory people commit. The plan, the restore modes and the cleanup of the
|
|
97
|
+
layout that kept snapshots inside `.claude`:
|
|
98
|
+
[the manual](docs/MANUAL.md#installing-safely--the-full-contract).
|
|
103
99
|
|
|
104
100
|
## Why it does not break
|
|
105
101
|
|
|
@@ -111,88 +107,63 @@ tarmac reads two things instead:
|
|
|
111
107
|
|
|
112
108
|
| Source | What it gives | How solid |
|
|
113
109
|
|---|---|---|
|
|
114
|
-
| `claude agents --json` | which sessions exist, busy or
|
|
110
|
+
| `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"*) |
|
|
115
111
|
| the status line payload | context %, model, effort, cost | the JSON Claude Code hands to your own `statusLine.command` on every frame — **observed, not published as a schema** |
|
|
116
112
|
|
|
117
113
|
That second line is the honest caveat, and it is the reason the real defence is not
|
|
118
114
|
immunity, it is **visible degradation**. A missing measurement is never a confident `0`: it
|
|
119
115
|
is an em dash that names which kind of missing it is — `absent` for a session no status line
|
|
120
116
|
ever wrote for, `fresh` for one that has taken no turn yet, `drift` for a release that moved
|
|
121
|
-
the payload out from under us
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
table is in [`docs/MANUAL.md`](docs/MANUAL.md).
|
|
126
|
-
|
|
127
|
-
## Commands
|
|
128
|
-
|
|
129
|
-
| Command | What it does | Options |
|
|
130
|
-
|---|---|---|
|
|
131
|
-
| `tarmac list` | one-shot fleet table — the default, so bare `tarmac` runs it | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
|
|
132
|
-
| `tarmac serve` | local dashboard, `GET /` for the table, `GET /map` for the map, `GET /live` for the fragment both refresh from, `GET /api/fleet` for JSON | `--home`, `--port`, `--stale-after`, `--snapshots-dir`, `--claude-bin` |
|
|
133
|
-
| `tarmac install` | chain the status line under `<home>/.claude/settings.json`, after confirmation | `--home`, `--yes` |
|
|
134
|
-
| `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
|
|
135
|
-
|
|
136
|
-
`--help` works everywhere. An option handed to a command that does not read it is an
|
|
137
|
-
**error**, not something quietly ignored — and the error names the commands it does belong
|
|
138
|
-
to. Both live views tell you **when the last good reading arrived** and **whether the last
|
|
139
|
-
refresh failed**: ages keep climbing whether or not the refresh works, failures are banners
|
|
140
|
-
with names, and a table is never thrown away for one.
|
|
117
|
+
the payload out from under us. A stale reading keeps its value and gets **dated** with a `!`.
|
|
118
|
+
A Claude Code build no fixture covers gets its version named *before* anything breaks, and
|
|
119
|
+
tarmac keeps reporting. The full state-by-state table is in
|
|
120
|
+
[the manual](docs/MANUAL.md#degradation-state-by-state).
|
|
141
121
|
|
|
142
122
|
## Configuration
|
|
143
123
|
|
|
144
124
|
Three numbers are opinions, not truths, so all three are yours; everything else is
|
|
145
125
|
deliberately not configurable, and all of it works with no configuration at all.
|
|
146
126
|
|
|
147
|
-
| Setting | Flag |
|
|
148
|
-
|
|
149
|
-
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `
|
|
150
|
-
| port | `--port 8080` | `
|
|
151
|
-
| snapshots dir (read side) | `--snapshots-dir DIR` |
|
|
127
|
+
| Setting | Flag | Default |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
|
|
130
|
+
| port | `--port 8080` | `4477` |
|
|
131
|
+
| snapshots dir (read side) | `--snapshots-dir DIR` | the path frozen into the installed wrapper, so the reader follows the writer |
|
|
152
132
|
|
|
133
|
+
Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
|
|
153
134
|
**Flag beats environment beats config file beats default**, settled per setting; `serve`
|
|
154
135
|
opens by printing each effective value and where it came from. Nothing is silently dropped
|
|
155
136
|
or silently corrected — a value that will not parse stops the run and says what it got,
|
|
156
|
-
where it came from, and what would have worked,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
That last default is read out of the installed wrapper, never recomputed: a reader that
|
|
160
|
-
recomputed it would disagree with the writer the moment the two saw different environments
|
|
161
|
-
— `XDG_STATE_HOME` exported in your shell, absent from a LaunchAgent or cron — and the
|
|
162
|
-
symptom is a healthy, empty fleet, the one failure a fleet monitor may not have. Anything
|
|
163
|
-
else reading those payloads can ask where they are: `tarmac list --json` reports the path as
|
|
164
|
-
`health.snapshotsDir`. Full rules and edge cases: [`docs/MANUAL.md`](docs/MANUAL.md).
|
|
165
|
-
|
|
166
|
-
`tarmac list --json` also reports `health.unfilable`: how many live sessions carry an id
|
|
167
|
-
tarmac will never file a snapshot under, so a reader can tell telemetry that is *late* from
|
|
168
|
-
telemetry that is *not coming*. See [`docs/MANUAL.md`](docs/MANUAL.md) for what makes an id
|
|
169
|
-
filable.
|
|
137
|
+
where it came from, and what would have worked. Spellings, edge cases and the two health
|
|
138
|
+
fields `tarmac list --json` reports: [the manual](docs/MANUAL.md#configuration).
|
|
170
139
|
|
|
171
140
|
## What it deliberately does not do
|
|
172
141
|
|
|
173
|
-
- **No "waiting for you"
|
|
174
|
-
a
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
142
|
+
- **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason —
|
|
143
|
+
a permission prompt, an open dialog — and tarmac draws exactly that, on both surfaces.
|
|
144
|
+
What it will not do is guess at the rest: a session that asked you a question in prose
|
|
145
|
+
still reports `idle`, and the only way to know better is to read a transcript, which is
|
|
146
|
+
the one thing this tool will not do. The signal is as good as the surface, and no better.
|
|
147
|
+
- **No history on disk.** `tarmac list` is a snapshot in time. A running `serve` holds the
|
|
148
|
+
last 24 hours of the readings it took itself, in memory, so the page can replay them — a
|
|
149
|
+
record that reaches no further back than the serve that took it, and goes when it goes.
|
|
150
|
+
None of it is written down.
|
|
178
151
|
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
179
152
|
- **No remote fleets.** It watches the machine it runs on.
|
|
180
153
|
|
|
181
154
|
## Development
|
|
182
155
|
|
|
183
156
|
```bash
|
|
184
|
-
npm test
|
|
185
|
-
npm run build
|
|
157
|
+
npm test # typecheck (src + test + scripts), then run the suite
|
|
158
|
+
npm run build # flat JavaScript into dist/
|
|
159
|
+
node scripts/demo-fleet.ts # the invented fleet the captures above are taken of
|
|
186
160
|
```
|
|
187
161
|
|
|
188
|
-
|
|
189
|
-
machine
|
|
190
|
-
|
|
191
|
-
the
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
The suite runs the TypeScript sources directly through Node's type stripping, so it needs
|
|
195
|
-
Node ≥ 22.18 to *develop*; what ships in `dist/` is plain ES2022 and runs on Node ≥ 20.
|
|
162
|
+
Every capture on this page is taken of a fleet that does not exist, because a screenshot of a
|
|
163
|
+
real machine carries working directories, prompts and costs, and nothing real enters this
|
|
164
|
+
repo. What CI covers, which Node version develops and which one ships:
|
|
165
|
+
[the manual](docs/MANUAL.md#developing). Capturing the fixtures for a new Claude Code build:
|
|
166
|
+
[the manual](docs/MANUAL.md#capturing-a-new-claude-code-version).
|
|
196
167
|
|
|
197
168
|
## License
|
|
198
169
|
|
package/dist/fleet.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { DEFAULT_STALE_AFTER_MS } from './config.js';
|
|
15
15
|
import { guardVersions } from './schema.js';
|
|
16
|
+
import { isWaiting } from './sessions.js';
|
|
16
17
|
import { SID_NAME } from './wrapper.js';
|
|
17
18
|
export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_STALE_AFTER_MS, discovery = null, }) {
|
|
18
19
|
const rows = sessions.map((s) => {
|
|
@@ -25,6 +26,7 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
25
26
|
pid: s.pid,
|
|
26
27
|
kind: s.kind,
|
|
27
28
|
status: s.status,
|
|
29
|
+
waitingFor: s.waitingFor,
|
|
28
30
|
busy: s.busy,
|
|
29
31
|
uptimeMs: typeof s.startedAt === 'number' ? now - s.startedAt : null,
|
|
30
32
|
ctxState: t ? t.ctxState : 'absent',
|
|
@@ -76,7 +78,10 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
76
78
|
// Tested tolerance from the fleet: `fresh` never counts, or a recycled fleet would
|
|
77
79
|
// raise this every single night.
|
|
78
80
|
schemaBroken: covered > 0 && drift === covered,
|
|
79
|
-
|
|
81
|
+
// Recomputed from the rows rather than taken from `discovery`, which may be null — so
|
|
82
|
+
// the exemption the reader grants a waiting session has to be granted again here, or
|
|
83
|
+
// the banner accuses a session both surfaces are drawing as waiting.
|
|
84
|
+
unknownStatus: rows.filter((r) => r.busy === null && !isWaiting(r)).length,
|
|
80
85
|
busy: rows.filter((r) => r.busy === true).length,
|
|
81
86
|
// A sum over 3 of 7 sessions is not the fleet's cost. Same rule as `sumUsage` one
|
|
82
87
|
// layer down: add only what is really a number, and count those — a payload with no
|
|
@@ -91,6 +96,98 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
91
96
|
},
|
|
92
97
|
};
|
|
93
98
|
}
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
/**
|
|
100
|
+
* How many sessions are working RIGHT NOW while looking at a reading that has gone cold, on a
|
|
101
|
+
* fleet where not one reading is fresh — and `0` whenever that whole picture does not hold.
|
|
102
|
+
*
|
|
103
|
+
* It exists because `health.stale` on its own is not news (#53). A statusline is written when
|
|
104
|
+
* a terminal draws a frame, so a session that idles overnight keeps yesterday's number and
|
|
105
|
+
* "N readings are stale" is what a resting fleet LOOKS like — the steady state, said again on
|
|
106
|
+
* every poll. The rows and the map nodes already date each reading one by one; a page-wide
|
|
107
|
+
* banner repeating it is wallpaper, and a warning nobody can ever act on trains the reader to
|
|
108
|
+
* skip the ones they can.
|
|
109
|
+
*
|
|
110
|
+
* What is not the steady state is this shape:
|
|
111
|
+
*
|
|
112
|
+
* • not one reading anywhere is fresh — so nothing is writing, rather than some sessions
|
|
113
|
+
* resting. A single fresh reading is proof the writer works, and ends the question.
|
|
114
|
+
* • and at least one of those cold readings belongs to a session that is BUSY. A busy
|
|
115
|
+
* session redraws its status line constantly, so its snapshot should be seconds old. Cold
|
|
116
|
+
* is the wrapper gone, the snapshot directory unwritable, a disk full — the writer, not
|
|
117
|
+
* the fleet.
|
|
118
|
+
*
|
|
119
|
+
* Both halves are needed. Busy-and-cold beside a fresh reading is one session's business — a
|
|
120
|
+
* terminal in a tmux window nobody has selected draws no frames while its session works, so
|
|
121
|
+
* its reading ages exactly like an idle one; everything cold with nobody busy is just the
|
|
122
|
+
* night.
|
|
123
|
+
*
|
|
124
|
+
* Readings only — a session with no snapshot has nothing that could have gone cold, and is the
|
|
125
|
+
* coverage warning's.
|
|
126
|
+
*
|
|
127
|
+
* Known window, and the reason there is no floor under it: a session that has just been given
|
|
128
|
+
* something to do is `busy` on the spine before its first frame lands, so a fleet waking from
|
|
129
|
+
* a quiet night can raise this for one poll. A grace period would be a second threshold
|
|
130
|
+
* nobody set, and the honest reading of that moment is that the newest reading on the machine
|
|
131
|
+
* is still hours old.
|
|
132
|
+
*/
|
|
133
|
+
export function busyOnStaleFleet(rows) {
|
|
134
|
+
// Every dated snapshot, INCLUDING one dated after the clock that read it. That is the whole
|
|
135
|
+
// handling of clock skew here, and it is deliberate: such a reading is never `stale` (a
|
|
136
|
+
// negative age is not greater than any threshold), so leaving it in the denominator makes
|
|
137
|
+
// `every` below fail and the verdict come to nothing — which is the answer we want. A
|
|
138
|
+
// snapshot the filesystem dates in the future may have been written a second ago, and that
|
|
139
|
+
// is the one fact that would prove the writer is alive, so it must not be filtered into
|
|
140
|
+
// silence. Excluding it was the first cut, and it let the page print "every context reading
|
|
141
|
+
// is stale" directly above its own warning naming the reading that was not.
|
|
142
|
+
//
|
|
143
|
+
// Not the same call `accountLimits` below makes, which refuses a skewed reading so it cannot
|
|
144
|
+
// WIN on freshness. The question there is which reading is youngest; here it is whether
|
|
145
|
+
// anything wrote at all, and for that an unreadable date must not be allowed to accuse.
|
|
146
|
+
const readings = rows.filter((r) => r.snapshotAgeMs !== null);
|
|
147
|
+
// A fleet with no readings needs no clause of its own: nothing is what `every` is vacuously
|
|
148
|
+
// true of, and nothing is also what the count below comes to.
|
|
149
|
+
if (!readings.every((r) => r.stale))
|
|
150
|
+
return 0;
|
|
151
|
+
// Strictly `true`: `null` is "tarmac cannot read this session's status", and a session that
|
|
152
|
+
// may or may not be working is not evidence that anything stopped.
|
|
153
|
+
return readings.filter((r) => r.busy === true).length;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The account's rate limits, as this fleet's sessions report them.
|
|
157
|
+
*
|
|
158
|
+
* They belong to the ACCOUNT and not to any one session, but they arrive per snapshot — so the
|
|
159
|
+
* rows do not carry contradicting numbers, they carry the same number at different ages, and
|
|
160
|
+
* the youngest is the one still true. Same rule as everything else in this module: the freshest
|
|
161
|
+
* reading wins.
|
|
162
|
+
*
|
|
163
|
+
* A snapshot dated AFTER the clock that read it is refused rather than believed, which is the
|
|
164
|
+
* verdict `map.ts` reaches on the same value: an NTP correction or a mount whose time runs
|
|
165
|
+
* ahead does not produce a small age, it produces something that is not an age at all — and
|
|
166
|
+
* being negative it would beat every real reading for as long as the skew lasts.
|
|
167
|
+
*
|
|
168
|
+
* Here rather than in either of its readers: the ring samples it every minute and the page's
|
|
169
|
+
* header draws it, and two copies of "which session's word counts" is two answers about one
|
|
170
|
+
* account the day either one is touched.
|
|
171
|
+
*/
|
|
172
|
+
export function accountLimits(rows) {
|
|
173
|
+
let freshest = null;
|
|
174
|
+
for (const r of rows) {
|
|
175
|
+
if (r.rateLimits === null || r.snapshotAgeMs === null || r.snapshotAgeMs < 0)
|
|
176
|
+
continue;
|
|
177
|
+
if (freshest === null || r.snapshotAgeMs < freshest.ageMs) {
|
|
178
|
+
freshest = { rateLimits: r.rateLimits, ageMs: r.snapshotAgeMs };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return freshest;
|
|
182
|
+
}
|
|
183
|
+
// Blocked on a human first, then busy, then unknown (it might be busy), then idle.
|
|
184
|
+
//
|
|
185
|
+
// Waiting leads because it is the only rank that is work for the reader: everything below it
|
|
186
|
+
// is the fleet reporting on itself, while a waiting session has STOPPED and will not start
|
|
187
|
+
// again until someone answers it. Ranking it by how busy it is — the question the rest of this
|
|
188
|
+
// sort asks — is what filed it with `unknown`, one bucket under a fleet that is mostly busy,
|
|
189
|
+
// which on the map is under the fold. It costs the ranks below it almost nothing: a fleet has
|
|
190
|
+
// one or two of these at a time, so busy moves down a row or two, and losing sight of a busy
|
|
191
|
+
// session for a poll is not a thing that can go wrong. Losing sight of a waiting one is.
|
|
192
|
+
const rank = (r) => isWaiting(r) ? 0 : r.busy === true ? 1 : r.busy === null ? 2 : 3;
|
|
96
193
|
const round2 = (n) => Math.round(n * 100) / 100;
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// P5 — what the serve already read, kept for a day.
|
|
2
|
+
//
|
|
3
|
+
// The dashboard reads the whole fleet on every request and forgets it on the next one, so a
|
|
4
|
+
// number that moved is a number nobody can point at afterwards. This is that reading, held.
|
|
5
|
+
//
|
|
6
|
+
// In memory and nowhere else, by design. A fleet journal on disk is the one file this tool
|
|
7
|
+
// promised never to write — it would outlive the process that made it, sit in someone's home
|
|
8
|
+
// carrying session ids, working directories and costs, and turn "tarmac reads, it does not
|
|
9
|
+
// keep" into a sentence with an asterisk. What a serve remembers therefore starts when that
|
|
10
|
+
// serve started, which is exactly what `since` says out loud.
|
|
11
|
+
//
|
|
12
|
+
// The ring is bounded before it is anything else: a serve is left open for days, and one
|
|
13
|
+
// sample a minute for a day is a size, while "everything since Tuesday" is a leak with a
|
|
14
|
+
// nicer name.
|
|
15
|
+
import { stateOf } from './map.js';
|
|
16
|
+
import { isWaiting } from './sessions.js';
|
|
17
|
+
import { accountLimits } from './fleet.js';
|
|
18
|
+
/** One sample a minute. Not a setting: see `HISTORY_SLOTS`. */
|
|
19
|
+
export const HISTORY_CADENCE_MS = 60_000;
|
|
20
|
+
/**
|
|
21
|
+
* 24 hours of them. One minute and one day are the product — a cadence knob would let a
|
|
22
|
+
* reader ask this process to spawn `claude agents --json` every second, and a retention knob
|
|
23
|
+
* would let them ask it to hold a week of fleets in RAM. A page that needs to say what it
|
|
24
|
+
* covers reads `since`.
|
|
25
|
+
*/
|
|
26
|
+
export const HISTORY_SLOTS = 1440;
|
|
27
|
+
export function createHistory({ since, cadence }) {
|
|
28
|
+
const slots = [];
|
|
29
|
+
// Whether anything has aged out yet, which is the only thing that can move `since` off the
|
|
30
|
+
// moment the serve started. A ring that is exactly full has dropped nothing.
|
|
31
|
+
let dropped = false;
|
|
32
|
+
const push = (slot) => {
|
|
33
|
+
slots.push(slot);
|
|
34
|
+
// Trimmed on the way in and never on the way out: the array's own length is the
|
|
35
|
+
// guarantee, so no reader has to be told the ring is bounded for it to be true.
|
|
36
|
+
if (slots.length > HISTORY_SLOTS) {
|
|
37
|
+
slots.splice(0, slots.length - HISTORY_SLOTS);
|
|
38
|
+
dropped = true;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
record(fleet) {
|
|
43
|
+
const sample = sampleOf(fleet);
|
|
44
|
+
push({ t: sample.t, sample });
|
|
45
|
+
},
|
|
46
|
+
miss(t) {
|
|
47
|
+
push({ t, sample: null });
|
|
48
|
+
},
|
|
49
|
+
// Rebuilt per read, and never the ring itself: this is the only copy there is and no file
|
|
50
|
+
// to restore it from, so a reader that spliced what it was handed would edit the record.
|
|
51
|
+
read: () => {
|
|
52
|
+
const samples = slots.filter((s) => s.sample !== null).map((s) => s.sample);
|
|
53
|
+
return {
|
|
54
|
+
since: dropped ? slots[0].t : since,
|
|
55
|
+
cadence,
|
|
56
|
+
samples,
|
|
57
|
+
missed: slots.length - samples.length,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A fleet reading, reduced to what a replay reads back.
|
|
64
|
+
*
|
|
65
|
+
* The names are the omission that matters, and it is not an oversight to be commented — it is
|
|
66
|
+
* pinned by a test. `claude agents --json` names a background session after the PROMPT it was
|
|
67
|
+
* given, so a fleet's names are a record of what its agents were told to do. The live view may
|
|
68
|
+
* show that, because someone is looking at their own screen in the present tense; a day of
|
|
69
|
+
* them, retained by a process and served back on a route, is a different object. No name for
|
|
70
|
+
* anyone, so there is no rule about kinds to get wrong later.
|
|
71
|
+
*/
|
|
72
|
+
function sampleOf({ rows, health }) {
|
|
73
|
+
return {
|
|
74
|
+
t: health.generatedAt,
|
|
75
|
+
sessions: rows.map((r) => ({
|
|
76
|
+
sid: r.sessionId,
|
|
77
|
+
project: r.project,
|
|
78
|
+
kind: r.kind,
|
|
79
|
+
state: stateOf(r),
|
|
80
|
+
waitingFor: isWaiting(r) ? r.waitingFor : null,
|
|
81
|
+
ctxState: r.ctxState,
|
|
82
|
+
ctxPct: r.ctxPct,
|
|
83
|
+
costUsd: r.costUsd,
|
|
84
|
+
})),
|
|
85
|
+
// The reading, without its age: the ring keeps each reading and never how old it was.
|
|
86
|
+
rateLimits: accountLimits(rows)?.rateLimits ?? null,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// P3 — the account's two windows.
|
|
2
|
+
//
|
|
3
|
+
// Every session on the page spends from the same two allowances, so they belong to the page
|
|
4
|
+
// and not to a node: five hours, and seven days. The statusline payload carries both, as a
|
|
5
|
+
// used percentage and the epoch each one rolls over at.
|
|
6
|
+
//
|
|
7
|
+
// This module reads a shape someone else versions, which is why it reads it the way
|
|
8
|
+
// `snapshots.ts` reads the context window: the PRESENCE of a key says whether a number was
|
|
9
|
+
// ever taken, and the value says what it was. A key that is gone, or holding something that is
|
|
10
|
+
// not a percentage, is a schema that moved — and neither one is 0%. There is exactly one thing
|
|
11
|
+
// a dashboard must never do with an account's limits, and that is report a window it could not
|
|
12
|
+
// read as a window with room to spare.
|
|
13
|
+
//
|
|
14
|
+
// The reset is turned into a stretch of time HERE, against the clock the reading was taken
|
|
15
|
+
// with, so that no renderer has to go and find a clock of its own: the live page subtracts from
|
|
16
|
+
// the moment it read the fleet, and a replay subtracts from the minute its sample was taken.
|
|
17
|
+
// Both then have one rule about what a negative answer means.
|
|
18
|
+
/**
|
|
19
|
+
* The two windows, in the order they are read. A table rather than two hand-written branches:
|
|
20
|
+
* the page script draws the same pair in the browser for a replayed minute, and it is handed
|
|
21
|
+
* this list rather than keeping a second copy of the vocabulary.
|
|
22
|
+
*/
|
|
23
|
+
export const LIMIT_WINDOWS = [
|
|
24
|
+
{ key: 'five_hour', label: '5h', said: 'five-hour window' },
|
|
25
|
+
{ key: 'seven_day', label: '7d', said: 'seven-day window' },
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* How far from the reading a reset may land and still be one.
|
|
29
|
+
*
|
|
30
|
+
* The same discipline the percentage gets, one field over: `used_percentage` outside 0-100 is
|
|
31
|
+
* refused rather than drawn, and a `resets_at` outside a plausible distance is refused for the
|
|
32
|
+
* same reason. The longest window here is seven days, so nothing this account resets at is more
|
|
33
|
+
* than eight away — while the two ways this field can move are both far outside that: the same
|
|
34
|
+
* number in milliseconds lands fifty thousand years out, and `0`, the sentinel an unset field so
|
|
35
|
+
* often is, lands in 1970. Both were rendered with a straight face ("resets in 19656250d").
|
|
36
|
+
*/
|
|
37
|
+
export const RESET_HORIZON_MS = 8 * 24 * 3600 * 1000;
|
|
38
|
+
/** Both windows, always — a window that could not be read is a gauge that says so. */
|
|
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
|
+
return LIMIT_WINDOWS.map(({ key, label, said }) => {
|
|
44
|
+
const w = ok ? rateLimits[key] : undefined;
|
|
45
|
+
const has = w !== null && typeof w === 'object' && !Array.isArray(w) && 'used_percentage' in w;
|
|
46
|
+
const v = has ? w.used_percentage : undefined;
|
|
47
|
+
// Present and null: a window whose number has not been taken yet. Absent, or holding
|
|
48
|
+
// something that is not a percentage: the shape moved. The discriminant is the key.
|
|
49
|
+
const pct = has && typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? Math.floor(v) : null;
|
|
50
|
+
const why = pct !== null ? null : rateLimits == null || (has && v === null) ? 'absent' : 'drift';
|
|
51
|
+
const at = has && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;
|
|
52
|
+
const resetsInMs = at === null ? null : at * 1000 - now;
|
|
53
|
+
return {
|
|
54
|
+
key,
|
|
55
|
+
label,
|
|
56
|
+
said,
|
|
57
|
+
pct,
|
|
58
|
+
why,
|
|
59
|
+
resetsInMs: resetsInMs === null || Math.abs(resetsInMs) > RESET_HORIZON_MS ? null : resetsInMs,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
package/dist/map.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A view over the fleet `buildFleet` already produced. It opens no second source: every
|
|
4
4
|
// field below is derived from a row that is already on the page as a table line.
|
|
5
|
+
import { isWaiting } from './sessions.js';
|
|
5
6
|
/**
|
|
6
7
|
* How recently a snapshot must have landed for its node to pulse. Two of the page's poll
|
|
7
8
|
* intervals (5s): a session whose terminal keeps drawing frames keeps its heartbeat across
|
|
@@ -107,4 +108,12 @@ function readingOf(r) {
|
|
|
107
108
|
return 'undated';
|
|
108
109
|
return r.stale ? 'stale' : 'live';
|
|
109
110
|
}
|
|
110
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The four words a node can be, out of a boolean that only has three answers.
|
|
113
|
+
*
|
|
114
|
+
* `waiting` is asked BEFORE the boolean's `null`, and that order is the point: a session
|
|
115
|
+
* halted until a human answers carries `busy: null` — "is it working" has no honest answer —
|
|
116
|
+
* which is the same null an unrecognised word gets. Left to the boolean alone, the one
|
|
117
|
+
* session that is blocked on YOU drew as the amber "tarmac does not know this word".
|
|
118
|
+
*/
|
|
119
|
+
export const stateOf = (r) => r.busy === true ? 'busy' : isWaiting(r) ? 'waiting' : r.busy === false ? 'idle' : 'unknown';
|