@adrrr/tarmac 0.2.0 → 0.4.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 +130 -55
- package/dist/fleet.js +45 -1
- package/dist/history.js +88 -0
- package/dist/install.js +44 -12
- package/dist/limits.js +62 -0
- package/dist/map.js +119 -0
- package/dist/reap.js +9 -4
- package/dist/render.js +851 -33
- package/dist/schema.js +1 -1
- package/dist/server.js +85 -4
- package/dist/sessions.js +36 -2
- package/dist/snapshots.js +19 -2
- package/dist/wrapper.js +101 -19
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# tarmac
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@adrrr/tarmac)
|
|
4
|
+
[](https://github.com/adrrr/tarmac/actions/workflows/ci.yml)
|
|
4
5
|

|
|
5
6
|

|
|
6
7
|

|
|
7
8
|
|
|
8
9
|
**Fleet observability for Claude Code.** One table for every session you have running —
|
|
9
|
-
busy or idle, how full its context is, which model, what it has cost so far.
|
|
10
|
+
busy or idle, how full its context is, which model, what it has cost so far. It reads
|
|
11
|
+
documented surfaces only, never an internal format.
|
|
10
12
|
|
|
11
13
|
```
|
|
12
|
-
$ npx @adrrr/tarmac
|
|
14
|
+
$ npx @adrrr/tarmac
|
|
13
15
|
|
|
14
16
|
PROJECT STATE CTX AS OF MODEL EFFORT COST UP
|
|
15
17
|
apollo busy 28% 7m Fable 5 max $41.20 8h
|
|
@@ -17,106 +19,179 @@ mercury-dashboard busy 27% 22m ! Fable 5 max $62.75 8h
|
|
|
17
19
|
gemini idle 12% 3h ! Opus 5 high $5.40 8h
|
|
18
20
|
atlas idle — fresh 8h ! Opus 5 high $0.00 8h
|
|
19
21
|
|
|
20
|
-
! 3 reading(s) marked "!" are older than
|
|
22
|
+
! 3 reading(s) marked "!" are older than 10m (--stale-after)
|
|
21
23
|
|
|
22
24
|
4 sessions · 2 busy · $109.35
|
|
23
25
|
```
|
|
24
26
|
|
|
27
|
+
That is the table with the status line chained. Without it — nothing installed at all — the
|
|
28
|
+
same command still lists every session, its state and its uptime, straight from
|
|
29
|
+
`claude agents --json`; the context column reads `— absent`, model, effort and cost fall to
|
|
30
|
+
`—`, and the line under the table counts how many sessions are covered.
|
|
31
|
+
|
|
32
|
+
Node ≥ 20. **Zero runtime dependencies** — no framework, no bundler, nothing to audit.
|
|
33
|
+
|
|
34
|
+
[Manual](docs/MANUAL.md) · [Changelog](CHANGELOG.md) · [Issues](https://github.com/adrrr/tarmac/issues)
|
|
35
|
+
|
|
25
36
|
## Install
|
|
26
37
|
|
|
27
38
|
```bash
|
|
28
|
-
npx @adrrr/tarmac
|
|
29
|
-
npx @adrrr/tarmac
|
|
30
|
-
npx @adrrr/tarmac
|
|
31
|
-
npx @adrrr/tarmac
|
|
32
|
-
npx @adrrr/tarmac uninstall #
|
|
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
|
|
33
44
|
```
|
|
34
45
|
|
|
35
|
-
|
|
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
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
47
74
|
|
|
48
75
|
## The dashboard
|
|
49
76
|
|
|
50
77
|
`tarmac serve` puts the same fleet in the browser — every session a row, ages that keep
|
|
51
|
-
climbing, and a banner the moment a refresh fails instead of a table quietly going stale
|
|
78
|
+
climbing, and a banner the moment a refresh fails instead of a table quietly going stale. It
|
|
79
|
+
prints the settings it resolved, then the URL it got:
|
|
52
80
|
|
|
53
81
|
```
|
|
54
82
|
tarmac serving http://127.0.0.1:4477
|
|
55
83
|
```
|
|
56
84
|
|
|
57
|
-
It binds to loopback and refuses any request whose `Host` is not loopback
|
|
58
|
-
paths and costs never leave the machine.
|
|
85
|
+
It binds to loopback and refuses any request whose `Host` is not loopback, or that a browser
|
|
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
|
+
In the header are the account's **two rate-limit gauges** — the five-hour window and the
|
|
91
|
+
seven-day one, each with its used percentage and its reset spelled as the time left. They are
|
|
92
|
+
page-level because that is what a rate limit is: one account, which every session below is
|
|
93
|
+
spending from. A fleet whose snapshots carry no limits says `— no reading` on a dotted rail
|
|
94
|
+
rather than drawing a window at 0%, and a reading past the freshness threshold is dated
|
|
95
|
+
`! 40m ago` — the countdown is recomputed every poll, the percentage is as old as its snapshot,
|
|
96
|
+
and a page that showed both as now would be lying with the moving one.
|
|
97
|
+
|
|
98
|
+
### The map
|
|
99
|
+
|
|
100
|
+
The tab in the header swaps the table for the same fleet as nodes — one per session, the arc
|
|
101
|
+
its context, the shape by the name its state, and a single halo when a reading for it landed
|
|
102
|
+
moments ago. It is the same reading in the same fragment, so the two views can never disagree.
|
|
103
|
+
|
|
104
|
+
The rules the table follows, the map follows: a reading past the freshness threshold is drawn
|
|
105
|
+
thin, amber and dated `! 3h ago` rather than as a live one, and a percentage nobody measured
|
|
106
|
+
is an empty dotted dial that names which kind of nothing it is — never a ring at zero, and
|
|
107
|
+
never a halo, however new the file it came in. A background agent is placed beside the session
|
|
108
|
+
sharing its working directory, because the working directory is the only thing the two provably
|
|
109
|
+
share; nothing is nested, and no edge is drawn for a relationship the sources do not publish.
|
|
110
|
+
Details in [the manual](docs/MANUAL.md#the-map).
|
|
111
|
+
|
|
112
|
+
Under the map is a **scrubber over the day this serve has seen**. Drag it and the dials render
|
|
113
|
+
the fleet as it was at that minute; press play and the day walks past. The record is fetched on
|
|
114
|
+
load, never per position, so scrubbing asks the server nothing. A replay never poses as the present: a banner names
|
|
115
|
+
the minute and holds the way back, the live fragment is hidden while it is up, halos stay off
|
|
116
|
+
because a sample never "just landed", and a session absent from a minute is absent from the
|
|
117
|
+
map. The range says what it truly covers — a serve ten minutes old offers ten minutes. The
|
|
118
|
+
account's gauges replay too, counted from the minute being shown rather than from now, so the
|
|
119
|
+
five-hour window can be watched draining and refilling across a day.
|
|
59
120
|
|
|
60
121
|
## Why it does not break
|
|
61
122
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
123
|
+
The usual ways to watch a Claude Code fleet read something Claude Code never promised would
|
|
124
|
+
stay put: transcript files, terminal panes, undocumented paths. Those break on an update,
|
|
125
|
+
and — worse — they break *quietly*, reporting a calm empty fleet.
|
|
65
126
|
|
|
66
127
|
tarmac reads two things instead:
|
|
67
128
|
|
|
68
129
|
| Source | What it gives | How solid |
|
|
69
130
|
|---|---|---|
|
|
70
|
-
| `claude agents --json` | which sessions exist, busy or
|
|
131
|
+
| `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"*) |
|
|
71
132
|
| 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** |
|
|
72
133
|
|
|
73
134
|
That second line is the honest caveat, and it is the reason the real defence is not
|
|
74
|
-
immunity, it is **visible degradation
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
135
|
+
immunity, it is **visible degradation**. A missing measurement is never a confident `0`: it
|
|
136
|
+
is an em dash that names which kind of missing it is — `absent` for a session no status line
|
|
137
|
+
ever wrote for, `fresh` for one that has taken no turn yet, `drift` for a release that moved
|
|
138
|
+
the payload out from under us — and the fleet-wide count sits under the table, as
|
|
139
|
+
`! statusline chained on 0/4 sessions`. A stale reading keeps its value and gets **dated**
|
|
140
|
+
with a `!`. And when a session shows up on a Claude Code build no fixture covers, tarmac
|
|
141
|
+
names that version *before* anything breaks, and keeps reporting. The full state-by-state
|
|
142
|
+
table is in [`docs/MANUAL.md`](docs/MANUAL.md).
|
|
79
143
|
|
|
80
144
|
## Commands
|
|
81
145
|
|
|
82
146
|
| Command | What it does | Options |
|
|
83
147
|
|---|---|---|
|
|
84
|
-
| `tarmac list` | one-shot fleet table | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
|
|
85
|
-
| `tarmac serve` | local dashboard, `GET /` for the
|
|
148
|
+
| `tarmac list` | one-shot fleet table — the default, so bare `tarmac` runs it | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
|
|
149
|
+
| `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, `GET /api/history` for the last 24h of readings it took while it ran | `--home`, `--port`, `--stale-after`, `--snapshots-dir`, `--claude-bin` |
|
|
86
150
|
| `tarmac install` | chain the status line under `<home>/.claude/settings.json`, after confirmation | `--home`, `--yes` |
|
|
87
151
|
| `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
|
|
88
152
|
|
|
89
153
|
`--help` works everywhere. An option handed to a command that does not read it is an
|
|
90
|
-
**error**, not something quietly ignored
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
banners with names, and a table is never thrown away for one. How each failure mode is
|
|
95
|
-
kept from looking like health: `docs/MANUAL.md`.
|
|
154
|
+
**error**, not something quietly ignored — and the error names the commands it does belong
|
|
155
|
+
to. Both live views tell you **when the last good reading arrived** and **whether the last
|
|
156
|
+
refresh failed**: ages keep climbing whether or not the refresh works, failures are banners
|
|
157
|
+
with names, and a table is never thrown away for one.
|
|
96
158
|
|
|
97
159
|
## Configuration
|
|
98
160
|
|
|
99
161
|
Three numbers are opinions, not truths, so all three are yours; everything else is
|
|
100
162
|
deliberately not configurable, and all of it works with no configuration at all.
|
|
101
163
|
|
|
102
|
-
| Setting | Flag | Environment |
|
|
164
|
+
| Setting | Flag | Environment | `<home>/.claude/tarmac/config.json` | Default |
|
|
103
165
|
|---|---|---|---|---|
|
|
104
166
|
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` | `"staleAfterMs": 90000` | `10m` |
|
|
105
167
|
| port | `--port 8080` | `TARMAC_PORT` | `"port": 8080` | `4477` |
|
|
106
|
-
| snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | `$XDG_STATE_HOME/tarmac/snapshots`, else `<home>/.local/state/tarmac/snapshots` |
|
|
107
|
-
|
|
108
|
-
**Flag beats environment beats config file beats default**, settled per setting
|
|
109
|
-
opens by printing each effective value and
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
168
|
+
| snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | the path frozen into the installed wrapper — failing that `$XDG_STATE_HOME/tarmac/snapshots`, when it is absolute *and* the target home is your own, else `<home>/.local/state/tarmac/snapshots` |
|
|
169
|
+
|
|
170
|
+
**Flag beats environment beats config file beats default**, settled per setting; `serve`
|
|
171
|
+
opens by printing each effective value and where it came from. Nothing is silently dropped
|
|
172
|
+
or silently corrected — a value that will not parse stops the run and says what it got,
|
|
173
|
+
where it came from, and what would have worked, *including* values that were going to lose
|
|
174
|
+
the precedence fight anyway.
|
|
175
|
+
|
|
176
|
+
That last default is read out of the installed wrapper, never recomputed: a reader that
|
|
177
|
+
recomputed it would disagree with the writer the moment the two saw different environments
|
|
178
|
+
— `XDG_STATE_HOME` exported in your shell, absent from a LaunchAgent or cron — and the
|
|
179
|
+
symptom is a healthy, empty fleet, the one failure a fleet monitor may not have. Anything
|
|
180
|
+
else reading those payloads can ask where they are: `tarmac list --json` reports the path as
|
|
181
|
+
`health.snapshotsDir`. Full rules and edge cases: [`docs/MANUAL.md`](docs/MANUAL.md).
|
|
182
|
+
|
|
183
|
+
`tarmac list --json` also reports `health.unfilable`: how many live sessions carry an id
|
|
184
|
+
tarmac will never file a snapshot under, so a reader can tell telemetry that is *late* from
|
|
185
|
+
telemetry that is *not coming*. See [`docs/MANUAL.md`](docs/MANUAL.md) for what makes an id
|
|
186
|
+
filable.
|
|
187
|
+
|
|
188
|
+
## What it deliberately does not do
|
|
189
|
+
|
|
190
|
+
- **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason —
|
|
191
|
+
a permission prompt, an open dialog — and tarmac draws exactly that, on both surfaces.
|
|
192
|
+
What it will not do is guess at the rest: a session that asked you a question in prose
|
|
193
|
+
still reports `idle`, and the only way to know better is to read a transcript, which is
|
|
194
|
+
the one thing this tool will not do. The signal is as good as the surface, and no better.
|
|
120
195
|
- **No history.** Each run is a snapshot in time; context and cost curves come later.
|
|
121
196
|
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
122
197
|
- **No remote fleets.** It watches the machine it runs on.
|
|
@@ -131,8 +206,8 @@ npm run build # flat JavaScript into dist/
|
|
|
131
206
|
CI runs the suite on Node 22 and 24, on Linux and macOS, with `TARMAC_REQUIRE_DASH=1` so a
|
|
132
207
|
machine without dash cannot report a green build it did not earn; a separate job builds
|
|
133
208
|
`dist/` and runs it on Node 20 — the oldest version `engines` promises, and the only place
|
|
134
|
-
the published artefact is ever executed. Releases are cut by hand (`PUBLISHING.md`);
|
|
135
|
-
capturing fixtures for a new Claude Code build: `docs/MANUAL.md
|
|
209
|
+
the published artefact is ever executed. Releases are cut by hand ([`PUBLISHING.md`](PUBLISHING.md));
|
|
210
|
+
capturing fixtures for a new Claude Code build: [`docs/MANUAL.md`](docs/MANUAL.md).
|
|
136
211
|
|
|
137
212
|
The suite runs the TypeScript sources directly through Node's type stripping, so it needs
|
|
138
213
|
Node ≥ 22.18 to *develop*; what ships in `dist/` is plain ES2022 and runs on Node ≥ 20.
|
package/dist/fleet.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
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';
|
|
17
|
+
import { SID_NAME } from './wrapper.js';
|
|
16
18
|
export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_STALE_AFTER_MS, discovery = null, }) {
|
|
17
19
|
const rows = sessions.map((s) => {
|
|
18
20
|
const t = (s.sessionId && snapshots.get(s.sessionId)) || null;
|
|
@@ -22,7 +24,9 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
22
24
|
project: s.cwd ? path.basename(s.cwd) : null,
|
|
23
25
|
cwd: s.cwd,
|
|
24
26
|
pid: s.pid,
|
|
27
|
+
kind: s.kind,
|
|
25
28
|
status: s.status,
|
|
29
|
+
waitingFor: s.waitingFor,
|
|
26
30
|
busy: s.busy,
|
|
27
31
|
uptimeMs: typeof s.startedAt === 'number' ? now - s.startedAt : null,
|
|
28
32
|
ctxState: t ? t.ctxState : 'absent',
|
|
@@ -39,6 +43,14 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
39
43
|
});
|
|
40
44
|
rows.sort((a, b) => rank(a) - rank(b) || (b.ctxPct ?? -1) - (a.ctxPct ?? -1));
|
|
41
45
|
const covered = rows.filter((r) => r.ctxState !== 'absent').length;
|
|
46
|
+
// Blind AND unfilable, in that order — this number exists to say how many of the blind will
|
|
47
|
+
// stay blind, and both renderers subtract it from them. A session can be unfilable and
|
|
48
|
+
// covered at the same time: a snapshot written by a pre-upgrade wrapper under a non-UUID
|
|
49
|
+
// name is still READ, because the reader keys on the `session_id` inside the file rather
|
|
50
|
+
// than on the filename. Counting that one would push this past the blind count and make the
|
|
51
|
+
// renderers explain away someone else's missing telemetry.
|
|
52
|
+
// A null id is `noSessionId`'s business — a discovery failure, not a naming one.
|
|
53
|
+
const unfilable = rows.filter((r) => r.ctxState === 'absent' && r.sessionId !== null && !SID_NAME.test(r.sessionId)).length;
|
|
42
54
|
const drift = rows.filter((r) => r.ctxState === 'drift').length;
|
|
43
55
|
// Having a snapshot and having a cost are different facts, and only the second one is
|
|
44
56
|
// allowed to feed the total.
|
|
@@ -55,6 +67,7 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
55
67
|
health: {
|
|
56
68
|
sessions: rows.length,
|
|
57
69
|
covered,
|
|
70
|
+
unfilable,
|
|
58
71
|
drift,
|
|
59
72
|
stale: rows.filter((r) => r.stale).length,
|
|
60
73
|
// Discovery's own blind spots. Dropping them turns a renamed `sessionId` into the
|
|
@@ -65,7 +78,10 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
65
78
|
// Tested tolerance from the fleet: `fresh` never counts, or a recycled fleet would
|
|
66
79
|
// raise this every single night.
|
|
67
80
|
schemaBroken: covered > 0 && drift === covered,
|
|
68
|
-
|
|
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,
|
|
69
85
|
busy: rows.filter((r) => r.busy === true).length,
|
|
70
86
|
// A sum over 3 of 7 sessions is not the fleet's cost. Same rule as `sumUsage` one
|
|
71
87
|
// layer down: add only what is really a number, and count those — a payload with no
|
|
@@ -80,6 +96,34 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
80
96
|
},
|
|
81
97
|
};
|
|
82
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The account's rate limits, as this fleet's sessions report them.
|
|
101
|
+
*
|
|
102
|
+
* They belong to the ACCOUNT and not to any one session, but they arrive per snapshot — so the
|
|
103
|
+
* rows do not carry contradicting numbers, they carry the same number at different ages, and
|
|
104
|
+
* the youngest is the one still true. Same rule as everything else in this module: the freshest
|
|
105
|
+
* reading wins.
|
|
106
|
+
*
|
|
107
|
+
* A snapshot dated AFTER the clock that read it is refused rather than believed, which is the
|
|
108
|
+
* verdict `map.ts` reaches on the same value: an NTP correction or a mount whose time runs
|
|
109
|
+
* ahead does not produce a small age, it produces something that is not an age at all — and
|
|
110
|
+
* being negative it would beat every real reading for as long as the skew lasts.
|
|
111
|
+
*
|
|
112
|
+
* Here rather than in either of its readers: the ring samples it every minute and the page's
|
|
113
|
+
* header draws it, and two copies of "which session's word counts" is two answers about one
|
|
114
|
+
* account the day either one is touched.
|
|
115
|
+
*/
|
|
116
|
+
export function accountLimits(rows) {
|
|
117
|
+
let freshest = null;
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
if (r.rateLimits === null || r.snapshotAgeMs === null || r.snapshotAgeMs < 0)
|
|
120
|
+
continue;
|
|
121
|
+
if (freshest === null || r.snapshotAgeMs < freshest.ageMs) {
|
|
122
|
+
freshest = { rateLimits: r.rateLimits, ageMs: r.snapshotAgeMs };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return freshest;
|
|
126
|
+
}
|
|
83
127
|
// busy first, then unknown (it might be busy), then idle
|
|
84
128
|
const rank = (r) => (r.busy === true ? 0 : r.busy === null ? 1 : 2);
|
|
85
129
|
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/install.js
CHANGED
|
@@ -238,22 +238,38 @@ function readLegacyDir(p) {
|
|
|
238
238
|
/**
|
|
239
239
|
* The three names the wrapper writes into its snapshot directory, and nothing else.
|
|
240
240
|
*
|
|
241
|
-
* `SNAPSHOT_NAME` is the
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
241
|
+
* `SNAPSHOT_NAME` is the writer's own rule now, not a second reading of it (#7): this purge
|
|
242
|
+
* deletes from inside `~/.claude`, a directory people version-control, and a `<sid>.json` is
|
|
243
|
+
* recognised by SHAPE alone — so the shape had better be one the wrapper can actually
|
|
244
|
+
* produce, or this deletes by resemblance in the worst place to be wrong about it.
|
|
245
|
+
*
|
|
246
|
+
* `TEMP_PREFIX` is deliberately the whole test for a temp file, and deliberately looser than
|
|
247
|
+
* `reap.ts`'s `<prefix><sid>.<pid>.tmp`. The two answer different questions. `reap` runs on
|
|
248
|
+
* every `serve` tick, in a directory a reader may have been pointed at and another program
|
|
249
|
+
* may own, so it takes only the exact name this wrapper emits. This runs once, at install,
|
|
250
|
+
* behind `tarmacWasInstalledHere` — provenance already proven — over a directory whose whole
|
|
251
|
+
* purpose was to be ours, and its job is to leave nothing behind so the directory itself can
|
|
252
|
+
* go. There, a name only tarmac ever writes is signature enough.
|
|
246
253
|
*/
|
|
247
254
|
const isPayloadName = (name) => SNAPSHOT_NAME.test(name) || name.startsWith(TEMP_PREFIX) || name === PRUNE_MARKER;
|
|
248
|
-
/**
|
|
249
|
-
|
|
255
|
+
/**
|
|
256
|
+
* What is at that path, with the two "no"s kept apart so a plan can say which one it is:
|
|
257
|
+
* nothing there at all, or something there that is not ours to take.
|
|
258
|
+
*
|
|
259
|
+
* `lstat`: the LINK's own kind decides, since `unlink` would remove the link, not its target.
|
|
260
|
+
* A judgement by `existsSync` would follow a dead symlink to "nothing there" while the removal
|
|
261
|
+
* left the link exactly where it was.
|
|
262
|
+
*/
|
|
263
|
+
const markerState = (file) => {
|
|
250
264
|
try {
|
|
251
|
-
return fs.lstatSync(file).isFile();
|
|
265
|
+
return fs.lstatSync(file).isFile() ? 'file' : 'not-a-file';
|
|
252
266
|
}
|
|
253
267
|
catch {
|
|
254
|
-
return
|
|
268
|
+
return 'none';
|
|
255
269
|
}
|
|
256
270
|
};
|
|
271
|
+
/** The same question, for callers that only need the yes. */
|
|
272
|
+
const isPlainFile = (file) => markerState(file) === 'file';
|
|
257
273
|
/**
|
|
258
274
|
* Did an install of OURS already exist here, before this run wrote anything?
|
|
259
275
|
*
|
|
@@ -721,6 +737,7 @@ export function planUninstall({ home, realHome = os.homedir() }) {
|
|
|
721
737
|
after = restored ? commandOf(settings.statusLine) : commandOf(current.statusLine);
|
|
722
738
|
}
|
|
723
739
|
const isRealHome = sameFile(root, realHome);
|
|
740
|
+
const snapshots = installedSnapshotsDir(p);
|
|
724
741
|
return {
|
|
725
742
|
action: 'uninstall',
|
|
726
743
|
home: root,
|
|
@@ -732,8 +749,10 @@ export function planUninstall({ home, realHome = os.homedir() }) {
|
|
|
732
749
|
after,
|
|
733
750
|
mode,
|
|
734
751
|
// Where they REALLY are: `uninstall` leaves them behind, so the path it prints has to be
|
|
735
|
-
// the wrapper's own, not one recomputed from this shell's environment
|
|
736
|
-
|
|
752
|
+
// the wrapper's own, not one recomputed from this shell's environment — and when the
|
|
753
|
+
// wrapper cannot answer, neither can the plan. The same `null` `uninstall` acts on.
|
|
754
|
+
snapshots,
|
|
755
|
+
marker: snapshots === null ? null : markerState(path.join(snapshots, PRUNE_MARKER)),
|
|
737
756
|
undo: undoCommand('install', root, isRealHome),
|
|
738
757
|
};
|
|
739
758
|
}
|
|
@@ -743,10 +762,20 @@ function installedBackupOrRefuse(p) {
|
|
|
743
762
|
throw new Error(`no tarmac install found under ${p.dir}`);
|
|
744
763
|
return backup;
|
|
745
764
|
}
|
|
765
|
+
function removePruneMarker(snapshots) {
|
|
766
|
+
if (snapshots === null)
|
|
767
|
+
return;
|
|
768
|
+
const marker = path.join(snapshots, PRUNE_MARKER);
|
|
769
|
+
if (isPlainFile(marker))
|
|
770
|
+
fs.rmSync(marker, { force: true });
|
|
771
|
+
}
|
|
746
772
|
export function uninstall({ home }) {
|
|
747
773
|
const root = requireHome(home);
|
|
748
774
|
const p = paths(root);
|
|
749
775
|
const backup = installedBackupOrRefuse(p);
|
|
776
|
+
// Read this before removing the wrapper: it is the source of truth when the install used
|
|
777
|
+
// XDG_STATE_HOME, and the marker is the only file in that directory uninstall owns.
|
|
778
|
+
const snapshots = installedSnapshotsDir(p);
|
|
750
779
|
const currentText = fs.existsSync(p.settings) ? fs.readFileSync(p.settings, 'utf8') : null;
|
|
751
780
|
let mode;
|
|
752
781
|
if (currentText === backup.installedText) {
|
|
@@ -774,7 +803,10 @@ export function uninstall({ home }) {
|
|
|
774
803
|
writeAtomic(p.settings, JSON.stringify(settings, null, 2) + '\n');
|
|
775
804
|
mode = 'surgical';
|
|
776
805
|
}
|
|
777
|
-
//
|
|
806
|
+
// Restore settings before touching runtime state: even an unreadable snapshots directory
|
|
807
|
+
// must not strand statusLine on the wrapper we are uninstalling.
|
|
808
|
+
removePruneMarker(snapshots);
|
|
809
|
+
// Snapshot payloads are data the user may still want; only what we generated goes.
|
|
778
810
|
fs.rmSync(p.wrapper, { force: true });
|
|
779
811
|
fs.rmSync(p.backup, { force: true });
|
|
780
812
|
return { mode };
|
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
|
+
}
|