@adrrr/tarmac 0.4.0 → 0.5.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 +95 -127
- package/dist/fleet.js +66 -2
- package/dist/map.js +37 -30
- package/dist/render.js +266 -45
- package/dist/schema.js +1 -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, 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
|
+
</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,93 +50,67 @@ 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 marks
|
|
62
|
+
as coming from another origin: your cwd paths and costs never leave the machine. A client that
|
|
63
|
+
sends no such mark — curl, a script — is left alone. The listening rules:
|
|
64
|
+
[the manual](docs/MANUAL.md#what-serve-listens-on).
|
|
80
65
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
66
|
+
<picture>
|
|
67
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
|
|
68
|
+
<img src="docs/media/map-light.png" width="1100"
|
|
69
|
+
alt="The tarmac map, live. Four framed groups, one per working directory and labelled with its project: beacon, holding a session halted on a permission prompt; harbor, holding a session busy with its context arc at 90% and a background agent docked under the cards of that frame as a strip named after its prompt; quay, reporting a status tarmac does not know; and atlas, idle at 36%. A frame says only that its nodes were read in the same directory — nothing inside one claims that any node dispatched another. The account's five-hour and seven-day gauges sit above them, and one warning above the fleet names the unrecognised status rather than filing it as idle.">
|
|
70
|
+
</picture>
|
|
84
71
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
72
|
+
The tab in the header swaps the table for the same fleet as nodes — one per session, the arc
|
|
73
|
+
its context, the shape by the name its state. A background agent has no terminal behind it to
|
|
74
|
+
draw a frame with, so it is a strip of text rather than a dial that could never fill. It is
|
|
75
|
+
the same reading in the same fragment, so the two views can never disagree.
|
|
76
|
+
|
|
77
|
+
The nodes are grouped by **working directory**: a frame per directory, labelled with its
|
|
78
|
+
project, the sessions inside it as cards and the agents docked underneath as strips. That is
|
|
79
|
+
the whole of what the frame claims — these were read in the same directory. It is not a
|
|
80
|
+
parentage: `claude agents --json` publishes nothing that ties an agent to whoever dispatched
|
|
81
|
+
it, so no label, no position and no line inside a frame says one node asked for another. An
|
|
82
|
+
agent whose directory matches no session gets a frame of its own rather than somebody else's.
|
|
83
|
+
|
|
84
|
+
Above the fleet are the account's five-hour and seven-day
|
|
85
|
+
[gauges](docs/MANUAL.md#the-accounts-two-windows); under it a **scrubber over the day this
|
|
86
|
+
serve has seen** — drag it and the nodes render
|
|
87
|
+
the fleet as it was at that minute, press play and the day walks past. Replayed, they are
|
|
88
|
+
drawn flat and unframed: the record keeps a project name and never the directory it was read
|
|
89
|
+
in, and a basename is not a directory — two checkouts of `atlas` answer to the same word, so a
|
|
90
|
+
frame drawn on it would claim a shared directory nobody can check.
|
|
89
91
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
The rules the table follows, the map follows: a reading past the freshness threshold is drawn
|
|
93
|
+
thin, amber and dated, a percentage nobody measured is an empty dotted dial rather than a ring
|
|
94
|
+
at zero, and where nothing was published at all — the strip of an agent the join found no
|
|
95
|
+
snapshot for — nothing is drawn in its place, neither a dial nor a dash. A word
|
|
96
|
+
`claude agents --json` printed that tarmac has no boolean for is shown as it came rather than
|
|
97
|
+
quietly filed as `idle` — and raises one warning above the fleet naming it. The page is JSON
|
|
98
|
+
underneath: `GET /api/fleet` and `GET /api/history` serve exactly what the map draws. Details
|
|
99
|
+
in [the manual](docs/MANUAL.md#the-map).
|
|
97
100
|
|
|
98
|
-
|
|
101
|
+
## Install
|
|
99
102
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
`install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
|
|
104
|
+
say-so alone. It prints the whole plan first — including the exact command that undoes it —
|
|
105
|
+
and waits for a **typed word** (`y` is not an answer; scripts pass `--yes`, deliberately).
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
107
|
+
A status line you already had is **wrapped, not replaced**: its display stays byte-identical,
|
|
108
|
+
and `uninstall` names which of its four restore modes ran. Two files land under `~/.claude/` —
|
|
109
|
+
the wrapper and the `backup.json` that undoes it — and neither changes at runtime: the
|
|
110
|
+
snapshots go to `~/.local/state/tarmac/snapshots` (`$XDG_STATE_HOME` when set), because
|
|
111
|
+
`~/.claude` is a directory people commit. The plan, the restore modes and the cleanup of the
|
|
112
|
+
layout that kept snapshots inside `.claude`:
|
|
113
|
+
[the manual](docs/MANUAL.md#installing-safely--the-full-contract).
|
|
120
114
|
|
|
121
115
|
## Why it does not break
|
|
122
116
|
|
|
@@ -135,55 +129,28 @@ That second line is the honest caveat, and it is the reason the real defence is
|
|
|
135
129
|
immunity, it is **visible degradation**. A missing measurement is never a confident `0`: it
|
|
136
130
|
is an em dash that names which kind of missing it is — `absent` for a session no status line
|
|
137
131
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
table is in [`docs/MANUAL.md`](docs/MANUAL.md).
|
|
143
|
-
|
|
144
|
-
## Commands
|
|
145
|
-
|
|
146
|
-
| Command | What it does | Options |
|
|
147
|
-
|---|---|---|
|
|
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` |
|
|
150
|
-
| `tarmac install` | chain the status line under `<home>/.claude/settings.json`, after confirmation | `--home`, `--yes` |
|
|
151
|
-
| `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
|
|
152
|
-
|
|
153
|
-
`--help` works everywhere. An option handed to a command that does not read it is an
|
|
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.
|
|
132
|
+
the payload out from under us. A stale reading keeps its value and gets **dated** with a `!`.
|
|
133
|
+
A Claude Code build no fixture covers gets its version named *before* anything breaks, and
|
|
134
|
+
tarmac keeps reporting. The full state-by-state table is in
|
|
135
|
+
[the manual](docs/MANUAL.md#degradation-state-by-state).
|
|
158
136
|
|
|
159
137
|
## Configuration
|
|
160
138
|
|
|
161
139
|
Three numbers are opinions, not truths, so all three are yours; everything else is
|
|
162
140
|
deliberately not configurable, and all of it works with no configuration at all.
|
|
163
141
|
|
|
164
|
-
| Setting | Flag |
|
|
165
|
-
|
|
166
|
-
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `
|
|
167
|
-
| port | `--port 8080` | `
|
|
168
|
-
| snapshots dir (read side) | `--snapshots-dir DIR` |
|
|
142
|
+
| Setting | Flag | Default |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
|
|
145
|
+
| port | `--port 8080` | `4477` |
|
|
146
|
+
| snapshots dir (read side) | `--snapshots-dir DIR` | the wrapper's frozen path when installed, else the XDG state directory |
|
|
169
147
|
|
|
148
|
+
Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
|
|
170
149
|
**Flag beats environment beats config file beats default**, settled per setting; `serve`
|
|
171
150
|
opens by printing each effective value and where it came from. Nothing is silently dropped
|
|
172
151
|
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,
|
|
174
|
-
|
|
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.
|
|
152
|
+
where it came from, and what would have worked. Spellings, edge cases and the two health
|
|
153
|
+
fields `tarmac list --json` reports: [the manual](docs/MANUAL.md#configuration).
|
|
187
154
|
|
|
188
155
|
## What it deliberately does not do
|
|
189
156
|
|
|
@@ -192,25 +159,26 @@ filable.
|
|
|
192
159
|
What it will not do is guess at the rest: a session that asked you a question in prose
|
|
193
160
|
still reports `idle`, and the only way to know better is to read a transcript, which is
|
|
194
161
|
the one thing this tool will not do. The signal is as good as the surface, and no better.
|
|
195
|
-
- **No history.**
|
|
162
|
+
- **No history on disk.** `tarmac list` is a snapshot in time. A running `serve` holds the
|
|
163
|
+
last 24 hours of the readings it took itself, in memory, so the page can replay them — a
|
|
164
|
+
record that reaches no further back than the serve that took it, and goes when it goes.
|
|
165
|
+
None of it is written down.
|
|
196
166
|
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
197
167
|
- **No remote fleets.** It watches the machine it runs on.
|
|
198
168
|
|
|
199
169
|
## Development
|
|
200
170
|
|
|
201
171
|
```bash
|
|
202
|
-
npm test
|
|
203
|
-
npm run build
|
|
172
|
+
npm test # typecheck (src + test + scripts), then run the suite
|
|
173
|
+
npm run build # flat JavaScript into dist/
|
|
174
|
+
node scripts/demo-fleet.ts # the invented fleet the captures above are taken of
|
|
204
175
|
```
|
|
205
176
|
|
|
206
|
-
|
|
207
|
-
machine
|
|
208
|
-
|
|
209
|
-
the
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
The suite runs the TypeScript sources directly through Node's type stripping, so it needs
|
|
213
|
-
Node ≥ 22.18 to *develop*; what ships in `dist/` is plain ES2022 and runs on Node ≥ 20.
|
|
177
|
+
Every capture on this page is taken of a fleet that does not exist, because a screenshot of a
|
|
178
|
+
real machine carries working directories, prompts and costs, and nothing real enters this
|
|
179
|
+
repo. What CI covers, which Node version develops and which one ships:
|
|
180
|
+
[the manual](docs/MANUAL.md#developing). Capturing the fixtures for a new Claude Code build:
|
|
181
|
+
[the manual](docs/MANUAL.md#capturing-a-new-claude-code-version).
|
|
214
182
|
|
|
215
183
|
## License
|
|
216
184
|
|
package/dist/fleet.js
CHANGED
|
@@ -96,6 +96,62 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
96
96
|
},
|
|
97
97
|
};
|
|
98
98
|
}
|
|
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
|
+
}
|
|
99
155
|
/**
|
|
100
156
|
* The account's rate limits, as this fleet's sessions report them.
|
|
101
157
|
*
|
|
@@ -124,6 +180,14 @@ export function accountLimits(rows) {
|
|
|
124
180
|
}
|
|
125
181
|
return freshest;
|
|
126
182
|
}
|
|
127
|
-
//
|
|
128
|
-
|
|
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;
|
|
129
193
|
const round2 = (n) => Math.round(n * 100) / 100;
|
package/dist/map.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
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
5
|
import { isWaiting } from './sessions.js';
|
|
6
|
+
/**
|
|
7
|
+
* What a berth is labelled when the source published no working directory for it. In the
|
|
8
|
+
* vocabulary the dials already use for a reading they do not have (`not chained`, `no turn
|
|
9
|
+
* yet`): a kind of nothing, named, rather than an empty frame that reads as a bug.
|
|
10
|
+
*/
|
|
11
|
+
const NO_DIRECTORY = 'no directory';
|
|
6
12
|
/**
|
|
7
13
|
* How recently a snapshot must have landed for its node to pulse. Two of the page's poll
|
|
8
14
|
* intervals (5s): a session whose terminal keeps drawing frames keeps its heartbeat across
|
|
@@ -13,18 +19,20 @@ import { isWaiting } from './sessions.js';
|
|
|
13
19
|
*/
|
|
14
20
|
export const PULSE_WITHIN_MS = 10_000;
|
|
15
21
|
/**
|
|
16
|
-
* Where
|
|
22
|
+
* Where a node goes, and why that is a grouping rather than a link.
|
|
17
23
|
*
|
|
18
24
|
* `claude agents --json` prints interactive and background sessions in one array, and
|
|
19
25
|
* publishes nothing that ties an agent to whoever dispatched it. The working directory is
|
|
20
|
-
* the only field both carry, so it is
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* the only field both carry, so it is the only thing nodes are grouped BY — they share a
|
|
27
|
+
* berth, and nothing is ever nested inside anything. Nesting would assert a parentage the
|
|
28
|
+
* source does not contain, and it would let this page show a smaller fleet than the table
|
|
29
|
+
* beside it.
|
|
24
30
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
31
|
+
* One pass, in the fleet's own order, and each berth takes its place at its FIRST node: the
|
|
32
|
+
* rank that lifts a session halted on a human above everything else lifts the frame around it
|
|
33
|
+
* too, whether the node that earned it is a session or an agent. An agent whose directory
|
|
34
|
+
* matches no session is a berth of its own, ordered like any other — being last was the flat
|
|
35
|
+
* grid's arrangement of it, never the data's.
|
|
28
36
|
*/
|
|
29
37
|
export function buildMap({ rows }, { pulseWithinMs = PULSE_WITHIN_MS } = {}) {
|
|
30
38
|
// Whether this fleet still speaks the kind we know. If NOTHING calls itself `interactive`,
|
|
@@ -53,31 +61,30 @@ export function buildMap({ rows }, { pulseWithinMs = PULSE_WITHIN_MS } = {}) {
|
|
|
53
61
|
row.snapshotAgeMs <= pulseWithinMs,
|
|
54
62
|
};
|
|
55
63
|
};
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const seen = new Set();
|
|
59
|
-
const nodes = [];
|
|
64
|
+
const berths = [];
|
|
65
|
+
const byCwd = new Map();
|
|
60
66
|
for (const r of rows) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
const n = node(r);
|
|
68
|
+
// Two directories nobody could read are not the same directory, so an absent cwd joins no
|
|
69
|
+
// key: it opens a berth of its own every time, which is the one honest frame around it.
|
|
70
|
+
// Absent in both its shapes — `readSessions` carries a `cwd` of `''` through verbatim, and
|
|
71
|
+
// one module down that empty string is already read as no directory at all (`project` comes
|
|
72
|
+
// out null). Keyed on it, every such node shared a frame captioned "no directory": the one
|
|
73
|
+
// claim this shape refuses, made by the nodes that can least support it.
|
|
74
|
+
let berth = !r.cwd ? undefined : byCwd.get(r.cwd);
|
|
75
|
+
if (berth === undefined) {
|
|
76
|
+
// The project, the directory itself, then the words — and the middle one is not a page
|
|
77
|
+
// that prints paths. `path.basename` answers the empty string for exactly one directory,
|
|
78
|
+
// the root, so a fleet with a session in `/` had a project of `''`: not the absent cwd
|
|
79
|
+
// above, and no name either. The fallback names it `/`, which is what was read.
|
|
80
|
+
berth = { label: r.project || r.cwd || NO_DIRECTORY, sessions: [], agents: [] };
|
|
81
|
+
berths.push(berth);
|
|
82
|
+
if (r.cwd)
|
|
83
|
+
byCwd.set(r.cwd, berth);
|
|
75
84
|
}
|
|
85
|
+
(n.role === 'session' ? berth.sessions : berth.agents).push(n);
|
|
76
86
|
}
|
|
77
|
-
|
|
78
|
-
if (!placed.has(a))
|
|
79
|
-
nodes.push(node(a));
|
|
80
|
-
return { nodes };
|
|
87
|
+
return { berths };
|
|
81
88
|
}
|
|
82
89
|
/**
|
|
83
90
|
* The kind a terminal calls itself, and the anchor this module reasons from. A background
|
package/dist/render.js
CHANGED
|
@@ -10,7 +10,7 @@ import { formatDuration } from './config.js';
|
|
|
10
10
|
import { buildMap, INTERACTIVE, stateOf } from './map.js';
|
|
11
11
|
import { schemaNotice } from './schema.js';
|
|
12
12
|
import { LIMIT_WINDOWS, RESET_HORIZON_MS, readLimits } from './limits.js';
|
|
13
|
-
import { accountLimits } from './fleet.js';
|
|
13
|
+
import { accountLimits, busyOnStaleFleet } from './fleet.js';
|
|
14
14
|
/**
|
|
15
15
|
* The other thing this module renders: the plan a user consents to before install or
|
|
16
16
|
* uninstall touches their settings.json. Everything the decision rests on has to be here —
|
|
@@ -278,16 +278,36 @@ export function renderLive(fleet) {
|
|
|
278
278
|
if (health.unknownStatus > 0) {
|
|
279
279
|
warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
|
|
280
280
|
}
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
// NOT "N readings are stale", which used to live here and was on every hour of every day
|
|
282
|
+
// (#53): a statusline is written when a terminal draws a frame, so a fleet that idles keeps
|
|
283
|
+
// yesterday's numbers and says so on every poll. The rows and the nodes date each reading
|
|
284
|
+
// themselves — a page-wide box repeating it is wallpaper, and wallpaper is what teaches a
|
|
285
|
+
// reader to skip the boxes below. What is left here is the one stale-shaped thing that is
|
|
286
|
+
// an event: `busyOnStaleFleet` (see fleet.ts for why both halves of it are needed).
|
|
287
|
+
const stalled = busyOnStaleFleet(rows);
|
|
288
|
+
if (stalled > 0) {
|
|
289
|
+
warnings.push(`Every context reading is stale, including ${stalled} session(s) busy right now — a busy session redraws its status line, so its reading should not be older than ${formatDuration(health.staleAfterMs)}. The statusline writer looks stopped rather than the fleet idle: check that the wrapper is still installed and that the snapshot directory is writable.`);
|
|
283
290
|
}
|
|
284
291
|
const skewed = rows.filter(ahead).length;
|
|
285
292
|
if (skewed > 0) {
|
|
286
293
|
warnings.push(`${skewed} reading(s) are dated in the future — ${SKEW}. They are shown undated rather than as brand new.`);
|
|
287
294
|
}
|
|
295
|
+
// Under the fleet, at a footnote's weight: two facts that are true, worth keeping, and worth
|
|
296
|
+
// nobody's alarm. The first is the legend for the marks the rows carry — a `!` whose
|
|
297
|
+
// threshold is invisible is a mark the reader cannot argue with, which is why demoting the
|
|
298
|
+
// banner above could not take the number with it. The second is a maintainer's line: it
|
|
299
|
+
// stands for every user of a released tarmac until the next release ships the fixture, so
|
|
300
|
+
// amber would mean amber forever. Both keep every word they had.
|
|
301
|
+
const notes = [];
|
|
302
|
+
// Not under the stall banner, which names the same threshold two lines up: the pair reads as
|
|
303
|
+
// the alarm followed by its own excuse, and the excuse is the reading the alarm exists to
|
|
304
|
+
// tell you not to accept.
|
|
305
|
+
if (health.stale > 0 && stalled === 0) {
|
|
306
|
+
notes.push(`Readings past the ${formatDuration(health.staleAfterMs)} freshness threshold are dated where they sit — a statusline is only written when its terminal draws a frame, so an idle session's number is "as of" its last one. Set another with --stale-after.`);
|
|
307
|
+
}
|
|
288
308
|
const schema = schemaNotice(health.schemaGuard);
|
|
289
309
|
if (schema)
|
|
290
|
-
|
|
310
|
+
notes.push(schema);
|
|
291
311
|
// Both views, every time, out of the one reading the page just asked for. The tabs are
|
|
292
312
|
// links and the shell decides which of the two is visible, so a fleet cannot be drawn as a
|
|
293
313
|
// table of one age beside a map of another.
|
|
@@ -297,17 +317,23 @@ export function renderLive(fleet) {
|
|
|
297
317
|
// on screen and read out all the same by anything going through the markup.
|
|
298
318
|
const body = rows.length === 0
|
|
299
319
|
? empty(health)
|
|
300
|
-
:
|
|
320
|
+
: // `aria-describedby` on both views, because demoting the footnote moved it BELOW every
|
|
321
|
+
// row and every node: a reader going through the markup now meets `! 3h ago` N times
|
|
322
|
+
// before anything says what threshold put it there. Sighted readers glance down; this
|
|
323
|
+
// is the same glance for anyone who cannot. The target is rendered whether or not it
|
|
324
|
+
// has anything in it, so the reference is never dangling.
|
|
325
|
+
`<div class="view view-table"><div class="wrap"><table aria-describedby="fleet-notes">
|
|
301
326
|
<thead><tr>
|
|
302
327
|
<th>Project</th><th>Session</th><th>State</th><th>Context</th><th>Model</th><th>Effort</th><th>Cost</th><th>Uptime</th>
|
|
303
328
|
</tr></thead>
|
|
304
329
|
<tbody>${rows.map(renderRow).join('')}</tbody>
|
|
305
330
|
</table></div></div>
|
|
306
|
-
<div class="view view-map">${renderMap(fleet)}</div>`;
|
|
331
|
+
<div class="view view-map" role="group" aria-label="fleet map" aria-describedby="fleet-notes">${renderMap(fleet)}</div>`;
|
|
307
332
|
return `<div id="limits-src" hidden>${renderLimits(fleet)}</div>
|
|
308
333
|
<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
|
|
309
334
|
${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
|
|
310
|
-
${body}
|
|
335
|
+
${body}
|
|
336
|
+
<div id="fleet-notes">${notes.map((n) => `<div class="note">${esc(n)}</div>`).join('')}</div>`;
|
|
311
337
|
}
|
|
312
338
|
/**
|
|
313
339
|
* The account's two windows, for the page's header.
|
|
@@ -463,6 +489,11 @@ export function renderPage(fleet, view = 'table') {
|
|
|
463
489
|
.warn { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:6px;
|
|
464
490
|
padding:.35rem .65rem; margin:.3rem 0; font-size:.8rem; line-height:1.45; }
|
|
465
491
|
.warn:last-of-type { margin-bottom:.9rem; }
|
|
492
|
+
/* The footnote: same words, none of the weight. Dim, small, below the fleet and with no box
|
|
493
|
+
around it, because what it carries is true rather than urgent — the threshold that dated a
|
|
494
|
+
reading, the payload shapes nobody has captured yet. It reads as chrome to someone
|
|
495
|
+
scanning their sessions and as an answer to someone who came looking for it. */
|
|
496
|
+
.note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:.9rem 0 0; max-width:95ch; }
|
|
466
497
|
.stale { color:var(--warn); font-weight:600; }
|
|
467
498
|
.wrap { overflow-x:auto; }
|
|
468
499
|
table { border-collapse:collapse; width:100%; min-width:44rem; }
|
|
@@ -479,7 +510,8 @@ export function renderPage(fleet, view = 'table') {
|
|
|
479
510
|
.pill.waiting { color:var(--wait); }
|
|
480
511
|
.pill.unknown { color:var(--warn); }
|
|
481
512
|
.pill.idle { color:var(--dim); font-weight:400; }
|
|
482
|
-
/*
|
|
513
|
+
/* Accented states carry their own hue down the row edge. Bold stays on busy alone — it
|
|
514
|
+
says "working", not "read me first"; waiting's weight is the top of the sort. */
|
|
483
515
|
td:first-child { border-left:3px solid transparent; }
|
|
484
516
|
tr[data-state="busy"] td:first-child { border-left-color:var(--busy); }
|
|
485
517
|
tr[data-state="waiting"] td:first-child { border-left-color:var(--wait); }
|
|
@@ -575,22 +607,86 @@ export function renderPage(fleet, view = 'table') {
|
|
|
575
607
|
/* ── the map ─────────────────────────────────────────────────────────────────────────
|
|
576
608
|
One node per session. The arc is the context, its weight is how much that reading may
|
|
577
609
|
be believed, and the halo — the only thing on this page that moves — says a frame
|
|
578
|
-
landed moments ago.
|
|
579
|
-
|
|
610
|
+
landed moments ago. A background agent is drawn with none of the three — there is no
|
|
611
|
+
terminal behind it to draw a statusline frame with — and is a strip instead, docked
|
|
612
|
+
under the cards of its berth, printing as text whatever its snapshot did publish.
|
|
613
|
+
|
|
614
|
+
Two layouts, each named, because the two surfaces know different things. The live map
|
|
615
|
+
groups by working directory (the berths below); the replay behind the scrubber keeps a
|
|
616
|
+
project name and never the directory it was read in, and a basename is not a directory —
|
|
617
|
+
a frame drawn on it would group two checkouts of one repository into one. So it stays the
|
|
618
|
+
flat grid this view was before, which is the honest drawing of what it holds. */
|
|
619
|
+
.map { gap:.9rem; }
|
|
620
|
+
.map.berths { display:flex; flex-wrap:wrap; align-items:flex-start; }
|
|
621
|
+
.map.flat { display:grid; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr)); }
|
|
622
|
+
/* The berth: a frame around the nodes read in one directory, and the label is the whole of
|
|
623
|
+
what it claims. Quiet on purpose — a hairline and a caption in the grey the rest of the
|
|
624
|
+
page uses for a heading, because the loud thing on this view is a session's state, and a
|
|
625
|
+
frame that competed with it would be a box drawn around a fact nobody asked about.
|
|
626
|
+
|
|
627
|
+
min-width:0 because a berth is a flex ITEM, and a flex item's automatic minimum size is
|
|
628
|
+
its min-content width — here the widest strip docked in it, whose prompt is one nowrap
|
|
629
|
+
line with no length limit. At auto the frame simply grows to fit the prompt: the
|
|
630
|
+
ellipsis on the strip still resolves, against a column that is never narrower than its own
|
|
631
|
+
text, so nothing is ever clipped and the page scrolls sideways instead. The flat grid gave
|
|
632
|
+
the strip a column to be cut to by being a grid; the frame has to say so. */
|
|
633
|
+
.berth { min-width:0; border:1px solid var(--line); border-radius:12px; padding:.6rem .7rem .7rem; }
|
|
634
|
+
.berth-label { margin:0 0 .5rem; font-size:.72rem; font-weight:600; text-transform:uppercase;
|
|
635
|
+
letter-spacing:.06em; color:var(--dim); }
|
|
636
|
+
/* The cards side by side at their own width, wrapping inside the frame when the directory
|
|
637
|
+
holds more of them than the row can take. */
|
|
638
|
+
.berth-cards { display:flex; flex-wrap:wrap; gap:.6rem; align-items:stretch; }
|
|
639
|
+
.berth-cards .node { width:10.5rem; }
|
|
640
|
+
/* And the strips docked underneath, full width of the frame, one under the other: a strip is
|
|
641
|
+
a line of text, and a line of text in a column half a card wide is an ellipsis where the
|
|
642
|
+
prompt was. Below the cards rather than among them because that is what it is — the
|
|
643
|
+
directory's background work, under the terminals someone is sitting at — and NOT because
|
|
644
|
+
one of those terminals dispatched it, which nothing here knows. */
|
|
645
|
+
.berth-strips { display:flex; flex-direction:column; gap:.4rem; margin-top:.6rem; }
|
|
580
646
|
.node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
|
|
581
647
|
display:flex; flex-direction:column; align-items:center; text-align:center; }
|
|
582
648
|
.node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
|
|
583
649
|
.node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
|
|
584
|
-
/* An agent is a smaller
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
650
|
+
/* An agent is not a smaller session — it is a strip. It was a card at three quarters scale,
|
|
651
|
+
which put a dial on a session that has no terminal to draw a statusline frame with: a ring
|
|
652
|
+
that can never fill, captioned with the words of a fault someone could go and repair. The
|
|
653
|
+
honest form is the one the table already speaks in — text on a line, left-aligned, its
|
|
654
|
+
state in the same glyph and in a three-pixel accent down the left edge. */
|
|
655
|
+
/* align-self, never the grid's own align-items: a strip is half the height of the card
|
|
656
|
+
beside it and must not be stretched to match, but the CARDS in a row still share one
|
|
657
|
+
height — telling the grid to stop stretching would have changed every session on the page
|
|
658
|
+
to make room for this one. Scoped to the flat grid, which is the only place a strip has a
|
|
659
|
+
card beside it: docked in a berth it is a full-width band, and "start" in that column
|
|
660
|
+
would shrink it to the width of its own prompt. */
|
|
661
|
+
.map.flat .node[data-role="agent"] { align-self:start; }
|
|
662
|
+
.node[data-role="agent"] { align-items:stretch; text-align:left;
|
|
663
|
+
padding:.5rem .7rem .55rem; border-radius:8px;
|
|
664
|
+
background:color-mix(in srgb, var(--line) 18%, transparent);
|
|
665
|
+
/* The box goes back to the neutral line the tinted rule above gave it: the accent is
|
|
666
|
+
the channel that carries state here, and a strip outlined in its hue as well was
|
|
667
|
+
the same fact said twice, in two weights, on a shape half the size of a card. */
|
|
668
|
+
border-color:var(--line); border-left-width:3px; border-left-color:var(--dim); }
|
|
669
|
+
.node[data-role="agent"][data-state="busy"] { border-left-color:var(--busy); }
|
|
670
|
+
.node[data-role="agent"][data-state="waiting"] { border-left-color:var(--wait); }
|
|
671
|
+
.node[data-role="agent"][data-state="unknown"] { border-left-color:var(--warn); }
|
|
672
|
+
.node[data-role="agent"] .who { margin-top:0; width:100%; }
|
|
673
|
+
/* A strip's project, which since the berths is the REPLAY's business alone: a live strip
|
|
674
|
+
prints none — the frame around it says the directory — and behind the scrubber there is no
|
|
675
|
+
frame, and the project is the only name the ring kept. The rule stayed when the markup
|
|
676
|
+
that used to need it went, or that name would sit at the body's own size, a size and a
|
|
677
|
+
half larger than the line it is on, on the one surface this suite renders no markup for. */
|
|
678
|
+
.node[data-role="agent"] .project { font-weight:600; font-size:.8rem; }
|
|
679
|
+
/* What the node calls itself, at the end of its line: an agent's line already reads as a
|
|
680
|
+
sentence, and the kind is the word that says it is not a terminal. This one and the prompt
|
|
681
|
+
below it are scoped to a node like every other rule here: both are words a table cell could
|
|
682
|
+
want the day it grows one, and unprefixed they would take it. */
|
|
683
|
+
.node .kind { margin-left:auto; font-size:.6rem; font-weight:700; text-transform:uppercase;
|
|
684
|
+
letter-spacing:.08em; color:var(--dim); }
|
|
685
|
+
/* The prompt a background session was named after — the strip's own line, now that the berth
|
|
686
|
+
around it carries the directory. One line, clipped: it is a sentence somebody typed, and
|
|
687
|
+
it is the only thing on the strip that has no length limit. */
|
|
688
|
+
.node .prompt { flex:1; min-width:0; color:var(--dim); font-size:.76rem;
|
|
689
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
594
690
|
/* Said, not shown: the three glyphs differ in silhouette, so a reader who cannot separate
|
|
595
691
|
two hues still has the state — but a screen reader is handed a bullet and nothing else. */
|
|
596
692
|
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
|
@@ -618,9 +714,17 @@ export function renderPage(fleet, view = 'table') {
|
|
|
618
714
|
/* Once per arrival, not forever: the fragment is replaced on every poll, so a single run
|
|
619
715
|
per swap is what makes the fleet breathe at the rate its frames actually land. A looping
|
|
620
716
|
animation would say "a frame just arrived" for five seconds after it stopped being true. */
|
|
621
|
-
|
|
717
|
+
/* What the halo says is that a frame landed, and it says it by being there at all. Its
|
|
718
|
+
COLOUR is free, and it was spending it on a claim: stroked with the busy hue under a lone
|
|
719
|
+
idle override, it pulsed green over an unrecognised status and — since the fourth state — over
|
|
720
|
+
a session halted on a human, in the hue of the one thing it is certainly not doing. Same
|
|
721
|
+
palette as the glyph under the name, off the same four states, so the two channels drawing
|
|
722
|
+
one node cannot end up disagreeing about it. */
|
|
723
|
+
.halo { fill:none; stroke:var(--dim); stroke-width:2; opacity:0; transform-origin:50% 50%;
|
|
622
724
|
animation:halo 1.6s ease-out 1; }
|
|
623
|
-
.node[data-state="
|
|
725
|
+
.node[data-state="busy"] .halo { stroke:var(--busy); }
|
|
726
|
+
.node[data-state="waiting"] .halo { stroke:var(--wait); }
|
|
727
|
+
.node[data-state="unknown"] .halo { stroke:var(--warn); }
|
|
624
728
|
@keyframes halo { from { opacity:.5; transform:scale(1); } to { opacity:0; transform:scale(1.22); } }
|
|
625
729
|
/* Motion is the one thing here nobody can look away from, so it is the first thing a
|
|
626
730
|
reader who asked for less of it stops getting. The reading is still readable without it. */
|
|
@@ -634,8 +738,12 @@ export function renderPage(fleet, view = 'table') {
|
|
|
634
738
|
.why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
|
|
635
739
|
.why b { display:block; font-size:1.25rem; font-weight:400; }
|
|
636
740
|
.who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
|
|
637
|
-
|
|
638
|
-
|
|
741
|
+
/* Two fields in one slot, and they are not the same fact. A live card names the SESSION —
|
|
742
|
+
the berth above it says the directory, and two sessions in one checkout are told apart by
|
|
743
|
+
nothing else. A replayed card has only the project: the ring never kept a name, for any
|
|
744
|
+
kind of session, so there is no berth behind the scrubber and no name to put in front. */
|
|
745
|
+
.who .name, .who .project { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
746
|
+
.node[data-state="busy"] .who .name, .node[data-state="busy"] .who .project { font-weight:700; }
|
|
639
747
|
.shape { font-size:.7rem; color:var(--dim); }
|
|
640
748
|
.node[data-state="busy"] .shape { color:var(--busy); }
|
|
641
749
|
.node[data-state="waiting"] .shape { color:var(--wait); }
|
|
@@ -646,7 +754,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
646
754
|
.sub.waiting-for { color:var(--wait); font-weight:600; }
|
|
647
755
|
.asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
|
|
648
756
|
.asof.stale { color:var(--warn); font-weight:600; }
|
|
649
|
-
@media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
|
|
757
|
+
@media (max-width: 30rem) { .map.flat { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); } .map { gap:.6rem; } }
|
|
650
758
|
|
|
651
759
|
/* Below this the table stops being a table: one card per session, every value keeping the
|
|
652
760
|
name of the column it came from. Nothing is dropped — a phone that hides the context
|
|
@@ -662,6 +770,24 @@ export function renderPage(fleet, view = 'table') {
|
|
|
662
770
|
tr[data-state="busy"] { border-left-color:var(--busy); }
|
|
663
771
|
tr[data-state="waiting"] { border-left-color:var(--wait); }
|
|
664
772
|
tr[data-state="unknown"] { border-left-color:var(--warn); }
|
|
773
|
+
/* The frames stop sharing a row, and the cards inside one stop being a fixed column so two
|
|
774
|
+
of them still fit across a phone. The berth keeps its hairline: a border around a card
|
|
775
|
+
inside a border around a directory is two hairlines, which is not the weight worth
|
|
776
|
+
spending a claim on — and dropping the frame here would drop the claim with it. */
|
|
777
|
+
.berth { width:100%; padding:.5rem .55rem .6rem; }
|
|
778
|
+
.berth-cards { gap:.5rem; }
|
|
779
|
+
/* min-width:0 for the reason the berth carries it, and here it is the width that was doing
|
|
780
|
+
the capping: a card's automatic minimum is its min-content width unless a specified size
|
|
781
|
+
suggests otherwise, and dropping the fixed column to auto drops that suggestion. A card
|
|
782
|
+
is then as wide as the name on it, and .who .name is one nowrap line — with a
|
|
783
|
+
background session named after its prompt, a name with no length limit. Not the exotic
|
|
784
|
+
case: when nothing in the fleet calls itself interactive, every row is drawn as a card. */
|
|
785
|
+
.berth-cards .node { flex:1 1 8.5rem; width:auto; min-width:0; }
|
|
786
|
+
/* A strip sharing a phone's width with a card is an ellipsis where the prompt was — the
|
|
787
|
+
one line saying what this agent was told to do is the first thing a narrow column takes
|
|
788
|
+
away. It spans the row instead, like the cells below it. The berth docks its own strips
|
|
789
|
+
full width at every size, so what this rule is left covering is the REPLAY's flat grid. */
|
|
790
|
+
.node[data-role="agent"] { grid-column:1 / -1; }
|
|
665
791
|
td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
|
|
666
792
|
display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
|
|
667
793
|
td::before { content:attr(data-label); color:var(--dim); font-size:.72rem; font-weight:600;
|
|
@@ -719,7 +845,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
719
845
|
would be a claim about nothing. -->
|
|
720
846
|
<div class="limits" id="replay-limits" role="group" aria-label="account rate limits, at the minute being replayed" hidden></div>
|
|
721
847
|
<div class="meta" id="replay-meta"></div>
|
|
722
|
-
<div class="map" id="replay-map"></div>
|
|
848
|
+
<div class="map flat" id="replay-map"></div>
|
|
723
849
|
</div>
|
|
724
850
|
<!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
|
|
725
851
|
what it says it covers is whatever the record answered with. -->
|
|
@@ -938,7 +1064,18 @@ function pageScript(view) {
|
|
|
938
1064
|
// A gap that says it is a gap is not a gap. The handle steps through readings, not
|
|
939
1065
|
// through minutes, and a record with holes in it is not a smooth walk.
|
|
940
1066
|
+ (record.missed ? ', ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
|
|
941
|
-
+ '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.'
|
|
1067
|
+
+ '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.'
|
|
1068
|
+
// The other thing the ring does not hold, said where the reader meets it: the argument
|
|
1069
|
+
// for an ungrouped replay was written in the README, the manual, the changelog and a
|
|
1070
|
+
// comment in this sheet, and nowhere the reader can see it. Shown less and told nothing,
|
|
1071
|
+
// a reader reads it as a rendering that broke.
|
|
1072
|
+
//
|
|
1073
|
+
// A standing property of the record, never an event. This line sits in the scrubber's own
|
|
1074
|
+
// block, outside the live fragment and outside the replay one, so it is on the page from
|
|
1075
|
+
// the moment the record lands — and a sentence saying the grouping had gone would be
|
|
1076
|
+
// printed under a live map with the grouping on it.
|
|
1077
|
+
+ ' It keeps a project name and never the directory a node was read in, so the past is'
|
|
1078
|
+
+ ' drawn ungrouped, in the order the sample carries.';
|
|
942
1079
|
}
|
|
943
1080
|
|
|
944
1081
|
function ready() {
|
|
@@ -1000,6 +1137,26 @@ function pageScript(view) {
|
|
|
1000
1137
|
// unescaped, and into the glyph slot as a function body.
|
|
1001
1138
|
var state = own(SHAPE, x.state) ? x.state : 'unknown';
|
|
1002
1139
|
var pct = typeof x.ctxPct === 'number' ? x.ctxPct : null;
|
|
1140
|
+
// The live view's rule about agents, in the copy of it that ships to the browser: a strip,
|
|
1141
|
+
// never a dial. A replay drawing agents as rings while the page one draws them as strips
|
|
1142
|
+
// would read as two kinds of thing — and the ring is the surface that can LEAST fill a
|
|
1143
|
+
// gauge, since it keeps a reading and never the terminal that produced it. No prompt line:
|
|
1144
|
+
// a background session is named after the prompt it was given, and the record stores no
|
|
1145
|
+
// names. What it does hold for one is a percentage and what it cost — printed like
|
|
1146
|
+
// anywhere else, the percentage labelled as the live strip labels it, since neither a ring
|
|
1147
|
+
// nor a column header is here to say which quantity it is. No model and no effort: a
|
|
1148
|
+
// sample is not a snapshot, and the record was never given either.
|
|
1149
|
+
if (role === 'agent') {
|
|
1150
|
+
return '<article class="node" data-role="agent" data-state="' + state + '" data-reading="undatable">'
|
|
1151
|
+
+ '<div class="who"><span class="shape" aria-hidden="true">' + SHAPE[state] + '</span>'
|
|
1152
|
+
+ '<span class="sr">' + state + '</span>'
|
|
1153
|
+
+ '<span class="project">' + esc(x.project) + '</span>'
|
|
1154
|
+
+ '<span class="kind">' + esc(x.kind) + '</span></div>'
|
|
1155
|
+
+ (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
|
|
1156
|
+
+ (pct === null ? '' : '<div class="sub">ctx ' + pct + '%</div>')
|
|
1157
|
+
+ (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
|
|
1158
|
+
+ '</article>';
|
|
1159
|
+
}
|
|
1003
1160
|
// The ring keeps each reading and never how old that reading was, so the arc weight that
|
|
1004
1161
|
// says how much a reading may be believed cannot be earned here. It is not the live
|
|
1005
1162
|
// default either: this third value is de-weighted in the stylesheet, and never the warning
|
|
@@ -1104,9 +1261,11 @@ function pageScript(view) {
|
|
|
1104
1261
|
function nodesOf(s) {
|
|
1105
1262
|
var anchored = false, html = '', i;
|
|
1106
1263
|
for (i = 0; i < s.sessions.length; i++) if (s.sessions[i].kind === INTERACTIVE) anchored = true;
|
|
1107
|
-
// In the order the sample carries. The live map
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1264
|
+
// In the order the sample carries, flat. The live map frames its nodes by working
|
|
1265
|
+
// directory; the ring holds a project name and never the directory it was read in, and a
|
|
1266
|
+
// basename is not a directory — a frame drawn on it would put two checkouts of one
|
|
1267
|
+
// repository behind one label. So the past keeps the order the fleet was sorted in, rather
|
|
1268
|
+
// than a grouping this page would have to invent a key for.
|
|
1110
1269
|
for (i = 0; i < s.sessions.length; i++) html += nodeOf(s.sessions[i], anchored);
|
|
1111
1270
|
return html;
|
|
1112
1271
|
}
|
|
@@ -1208,9 +1367,10 @@ function pageScript(view) {
|
|
|
1208
1367
|
`;
|
|
1209
1368
|
}
|
|
1210
1369
|
/**
|
|
1211
|
-
* The sort puts
|
|
1212
|
-
*
|
|
1213
|
-
* for the ones that are
|
|
1370
|
+
* The sort puts waiting first — the one row that is work for the reader — then busy, then
|
|
1371
|
+
* unknown, idle last. This is where that order is given its weight — an accent down the row
|
|
1372
|
+
* in the state's own hue, a bold name for the ones that are working, a quiet row for the
|
|
1373
|
+
* ones that are not.
|
|
1214
1374
|
*
|
|
1215
1375
|
* The state travels three ways at once: a shape, a word, and an attribute. Colour alone is
|
|
1216
1376
|
* no signal to a reader who cannot separate two of ours, and `data-state` is what the narrow
|
|
@@ -1259,21 +1419,45 @@ const stateLabel = (state, r) => state === 'waiting' && r.waitingFor ? `${stateW
|
|
|
1259
1419
|
*/
|
|
1260
1420
|
const CTX_WHY = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' };
|
|
1261
1421
|
/**
|
|
1262
|
-
* The map: one node per session,
|
|
1263
|
-
* its business — `renderLive` says that once, above both views, rather than
|
|
1264
|
-
* them render the same sentence and hide one of the two.
|
|
1422
|
+
* The map: one node per session, grouped into berths rather than laid out as a graph. An empty
|
|
1423
|
+
* fleet is not its business — `renderLive` says that once, above both views, rather than
|
|
1424
|
+
* letting each of them render the same sentence and hide one of the two.
|
|
1265
1425
|
*
|
|
1266
1426
|
* There are no edges because the sources publish no relationship between two sessions — the
|
|
1267
|
-
* one thing they do carry is the working directory, and that is
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1427
|
+
* one thing they do carry is the working directory, and that is what a berth is drawn around.
|
|
1428
|
+
* A frame is the cheapest way to say "these were read in one place" and the hardest to
|
|
1429
|
+
* misread as a line between two of them.
|
|
1270
1430
|
*
|
|
1271
1431
|
* Everything a reader interprets is decided in `map.ts` and rendered here, on the server,
|
|
1272
1432
|
* for the same reason the table is: the rules that keep a reading honest are tested, and a
|
|
1273
1433
|
* copy of them re-derived in browser JavaScript would sit where this suite cannot reach.
|
|
1274
1434
|
*/
|
|
1275
1435
|
export function renderMap(fleet) {
|
|
1276
|
-
return `<div class="map">${buildMap(fleet).
|
|
1436
|
+
return `<div class="map berths">${buildMap(fleet).berths.map(renderBerth).join('')}</div>`;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* One berth: a frame, a label, the cards of the directory, and the strips docked under them.
|
|
1440
|
+
*
|
|
1441
|
+
* The label is the WHOLE of what the frame claims — these nodes were read in this directory.
|
|
1442
|
+
* Nothing in here says which node dispatched which, because `claude agents --json` publishes
|
|
1443
|
+
* no such field: no order, no position and no line inside the frame means "parent of". The day
|
|
1444
|
+
* that relation is published it is drawn between nodes already sitting side by side, and this
|
|
1445
|
+
* function is where it would go — inside a berth, without moving one.
|
|
1446
|
+
*
|
|
1447
|
+
* Named for the reader who is handed no border at all: the frame is a group with the project
|
|
1448
|
+
* for its name, and the heading says the same word for one navigating by headings. Each half
|
|
1449
|
+
* is drawn only if it has something in it, so an orphan agent's berth is a frame with a strip
|
|
1450
|
+
* in it rather than a frame with an empty row above one.
|
|
1451
|
+
*
|
|
1452
|
+
* `role="group"` explicitly, which is what a named `<section>` would NOT be: that is a region,
|
|
1453
|
+
* a landmark, and one per working directory turns a busy machine into a page of landmarks all
|
|
1454
|
+
* named after a basename — several of them possibly the same basename, since two checkouts of
|
|
1455
|
+
* `atlas` are two berths with one label. The name is what this frame is worth to a screen
|
|
1456
|
+
* reader; a place in the landmark index is not, and `group` is the idiom the page already uses
|
|
1457
|
+
* for every other named box on it.
|
|
1458
|
+
*/
|
|
1459
|
+
function renderBerth({ label, sessions, agents }) {
|
|
1460
|
+
return `<section class="berth" role="group" aria-label="${esc(label)}"><h2 class="berth-label">${esc(label)}</h2>${sessions.length === 0 ? '' : `<div class="berth-cards">${sessions.map(renderNode).join('')}</div>`}${agents.length === 0 ? '' : `<div class="berth-strips">${agents.map(renderNode).join('')}</div>`}</section>`;
|
|
1277
1461
|
}
|
|
1278
1462
|
/**
|
|
1279
1463
|
* One node. Five facts, in five channels that do not depend on colour alone: the arc is how
|
|
@@ -1285,6 +1469,11 @@ export function renderMap(fleet) {
|
|
|
1285
1469
|
* One state brings a caption with it. A waiting session is the only one where the shape
|
|
1286
1470
|
* leaves a question the source can answer — which human answer it is halted on — and it is
|
|
1287
1471
|
* printed directly under the name, not hidden in a title attribute nobody hovers on a phone.
|
|
1472
|
+
*
|
|
1473
|
+
* Two shapes, and the split is what a node HAS rather than what it is worth: a session has a
|
|
1474
|
+
* terminal, so the dial and its four facts are drawn for it. A background agent has no
|
|
1475
|
+
* terminal to draw a frame with, and gets the strip below — same data attributes, same glyph,
|
|
1476
|
+
* same words to a screen reader, and whatever its snapshot published, as text on one line.
|
|
1288
1477
|
*/
|
|
1289
1478
|
function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
1290
1479
|
// The model owns "is there a number"; this reads its verdict rather than asking the row a
|
|
@@ -1300,18 +1489,50 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
|
1300
1489
|
: reading === 'undated'
|
|
1301
1490
|
? `<div class="asof stale">! undated</div>`
|
|
1302
1491
|
: '';
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1492
|
+
// The strip. What it dropped was the dial, never the reading: the ring on an agent could
|
|
1493
|
+
// never fill — there is no terminal here to draw a statusline frame with — and the middle of
|
|
1494
|
+
// it read "not chained", the vocabulary of a repairable fault ("run `tarmac install`") said
|
|
1495
|
+
// about a session no install can ever cover. So no gauge, no dash, no reason where the
|
|
1496
|
+
// source published nothing, and the three fields it does publish about an agent in text: its
|
|
1497
|
+
// state, the kind it calls itself, and the prompt it was named after.
|
|
1498
|
+
//
|
|
1499
|
+
// Nor a halo: it is a ring drawn inside the dial, and this shape has neither. What it says —
|
|
1500
|
+
// a reading landed seconds ago — is the one claim on this page nobody can look away from,
|
|
1501
|
+
// and it is not the fact a strip exists to carry.
|
|
1502
|
+
//
|
|
1503
|
+
// Nor the project: the berth around this strip says the directory once, for every node in
|
|
1504
|
+
// it, and a strip that repeated it would print `harbor` four times inside one frame. What
|
|
1505
|
+
// the line spends itself on instead is what tells two agents in one berth apart — the prompt
|
|
1506
|
+
// it was named after, and the kind it calls itself. Nothing here points at a node beside it:
|
|
1507
|
+
// sharing a frame is sharing a directory, and that is all it has ever been.
|
|
1508
|
+
if (role === 'agent') {
|
|
1509
|
+
// The rule for the rest: the strip prints what that session's snapshot published, and
|
|
1510
|
+
// nothing where nothing was published. The percentage, the model and the effort come out
|
|
1511
|
+
// of one file — `buildFleet` reads all three off the same object — so an agent the join
|
|
1512
|
+
// found a payload for shows all of them, on one line, beside the reading's age when it is
|
|
1513
|
+
// one nobody should take for current. The number carries its own label: a card has a ring
|
|
1514
|
+
// around it and the table a column header over it, and a bare `61%` under a line of prompt
|
|
1515
|
+
// reads as how much of the prompt is done. Each part is dropped on its own field being
|
|
1516
|
+
// null — a snapshot with no turn behind it has a model in it and no percentage.
|
|
1517
|
+
const published = [pct === null ? null : `ctx ${pct}%`, r.model, r.effort]
|
|
1518
|
+
.filter((v) => v !== null && v !== '')
|
|
1519
|
+
.map(esc)
|
|
1520
|
+
.join(' · ');
|
|
1521
|
+
return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
|
|
1522
|
+
<div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="prompt">${esc(r.name)}</span><span class="kind">${esc(r.kind)}</span></div>
|
|
1523
|
+
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|
|
1524
|
+
${published === '' ? '' : `<div class="sub">${published}</div>`}
|
|
1525
|
+
${asOf}
|
|
1526
|
+
</article>`;
|
|
1527
|
+
}
|
|
1306
1528
|
return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
|
|
1307
1529
|
<div class="dial">
|
|
1308
1530
|
<svg viewBox="0 0 80 80" aria-hidden="true">${pulse ? `<circle class="halo" cx="40" cy="40" r="${DIAL_R}"/>` : ''}<circle class="track${measured ? '' : ' unmeasured'}" cx="40" cy="40" r="${DIAL_R}"/>${pct === null ? '' : arc(pct)}</svg>
|
|
1309
1531
|
<div class="val">${value}</div>
|
|
1310
1532
|
${pulse ? `<span class="sr">a reading just landed</span>` : ''}
|
|
1311
1533
|
</div>
|
|
1312
|
-
<div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="
|
|
1534
|
+
<div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="name">${esc(r.name)}</span></div>
|
|
1313
1535
|
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|
|
1314
|
-
<div class="sub">${esc(r.name)}</div>
|
|
1315
1536
|
${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
|
|
1316
1537
|
<div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
|
|
1317
1538
|
${asOf}
|
package/dist/schema.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrrr/tarmac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
37
37
|
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
38
38
|
"typecheck": "tsc -p tsconfig.json",
|
|
39
|
-
"test": "npm run typecheck && node --test --test-timeout=120000
|
|
39
|
+
"test": "npm run typecheck && node --test --test-timeout=120000 \"test/*.test.ts\"",
|
|
40
40
|
"fixtures:capture": "node scripts/capture-fixtures.ts",
|
|
41
41
|
"prepack": "npm run build",
|
|
42
42
|
"prepublishOnly": "npm run build && TARMAC_REQUIRE_DASH=1 npm test && node dist/cli.js --help > /dev/null"
|