@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 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
- [Manual](docs/MANUAL.md) · [Changelog](CHANGELOG.md) · [Issues](https://github.com/adrrr/tarmac/issues)
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
- prints the settings it resolved, then the URL it got:
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
- tarmac serving http://127.0.0.1:4477
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
- 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.
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
- 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.
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
- ### The map
101
+ ## Install
99
102
 
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
+ `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
- 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.
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 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).
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 | Environment | `<home>/.claude/tarmac/config.json` | Default |
165
- |---|---|---|---|---|
166
- | freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` | `"staleAfterMs": 90000` | `10m` |
167
- | port | `--port 8080` | `TARMAC_PORT` | `"port": 8080` | `4477` |
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` |
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, *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.
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.** Each run is a snapshot in time; context and cost curves come later.
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 # typecheck (src + test + scripts), then run the suite
203
- npm run build # flat JavaScript into dist/
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
- CI runs the suite on Node 22 and 24, on Linux and macOS, with `TARMAC_REQUIRE_DASH=1` so a
207
- machine without dash cannot report a green build it did not earn; a separate job builds
208
- `dist/` and runs it on Node 20 — the oldest version `engines` promises, and the only place
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).
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
- // busy first, then unknown (it might be busy), then idle
128
- const rank = (r) => (r.busy === true ? 0 : r.busy === null ? 1 : 2);
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 an agent is placed, and why it is a placement rather than a link.
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 what an agent is placed BY — it lands next to the
21
- * session sharing its directory, and nothing is ever nested inside anything. Nesting would
22
- * assert a parentage the source does not contain, and it would let this page show a smaller
23
- * fleet than the table beside it.
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
- * The agents are gathered separately because the fleet sorts busy sessions first, so one can
26
- * arrive before the session it belongs beside. An agent whose directory matches no session
27
- * keeps a node of its own, at the end.
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 agents = rows.filter((r) => roleOf(r) === 'agent');
57
- const placed = new Set();
58
- const seen = new Set();
59
- const nodes = [];
64
+ const berths = [];
65
+ const byCwd = new Map();
60
66
  for (const r of rows) {
61
- if (roleOf(r) !== 'session')
62
- continue;
63
- nodes.push(node(r));
64
- // Only the first session of a directory collects them, or two sessions in one checkout
65
- // would each grow a copy of the same agents.
66
- if (r.cwd === null || seen.has(r.cwd))
67
- continue;
68
- seen.add(r.cwd);
69
- for (const a of agents) {
70
- // Two directories nobody could read are not the same directory.
71
- if (a.cwd === null || a.cwd !== r.cwd)
72
- continue;
73
- nodes.push(node(a));
74
- placed.add(a);
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
- for (const a of agents)
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
- if (health.stale > 0) {
282
- warnings.push(`${health.stale} reading(s) are older than the ${formatDuration(health.staleAfterMs)} freshness threshold — 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.`);
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
- warnings.push(schema);
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
- : `<div class="view view-table"><div class="wrap"><table>
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
- /* The weight the sort deserves: busy rows carry an accent and a bold name. */
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
- .map { display:grid; gap:.9rem; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr)); }
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 body in the same system, next to the session it shares a directory
585
- with never inside it. Tinted rather than outlined, and hooked, so it reads as the
586
- session's dependent without a line claiming a parentage the source never published. */
587
- .node[data-role="agent"] { padding-top:.55rem; border-color:transparent;
588
- background:color-mix(in srgb, var(--line) 22%, transparent); }
589
- .node[data-role="agent"] .dial { width:3.6rem; height:3.6rem; }
590
- .node[data-role="agent"] .pct { font-size:.95rem; }
591
- .node[data-role="agent"] .why { font-size:.6rem; max-width:3.4rem; }
592
- .node[data-role="agent"] .why b { font-size:1rem; }
593
- .node[data-role="agent"] .project { font-weight:400; }
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
- .halo { fill:none; stroke:var(--busy); stroke-width:2; opacity:0; transform-origin:50% 50%;
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="idle"] .halo { stroke:var(--dim); }
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
- .who .project { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
638
- .node[data-state="busy"] .who .project { font-weight:700; }
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 places an agent beside the session it
1108
- // shares a directory with; the ring holds no directory, so the past is drawn in the order
1109
- // the fleet was sorted in rather than in a grouping this page would have to invent.
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 busy first, unknown next, idle last. This is where that order is given its
1212
- * weight an accent down the row and a bold name for the ones that are working, a quiet row
1213
- * for the ones that are not.
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, laid out as a grid rather than a graph. An empty fleet is not
1263
- * its business — `renderLive` says that once, above both views, rather than letting each of
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 expressed by putting an
1268
- * agent NEXT to the session it shares a directory with, never by drawing a line that would
1269
- * claim more than the data says.
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).nodes.map(renderNode).join('')}</div>`;
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
- // An agent carries its own project, like every other node. Placement is not a promise —
1304
- // the grid wraps where the viewport says, and the fleet's sort can hand the same agent a
1305
- // different neighbour on the next poll so nothing here points at the node beside it.
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="project">${esc(r.project)}</span></div>
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
@@ -29,7 +29,7 @@
29
29
  * directory, so the two cannot drift apart in the repo.
30
30
  */
31
31
  export const CHECKED_VERSIONS = {
32
- statusline: ['2.1.220', '2.1.226'],
32
+ statusline: ['2.1.220', '2.1.226', '2.1.232'],
33
33
  agents: ['2.1.226', '2.1.232'],
34
34
  };
35
35
  const SURFACE_LABEL = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.4.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 --test-force-exit \"test/*.test.ts\"",
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"