@adrrr/tarmac 0.4.1 → 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 +29 -14
- package/dist/map.js +37 -30
- package/dist/render.js +212 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ documented surfaces only, never an internal format.
|
|
|
13
13
|
<picture>
|
|
14
14
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/replay-dark.gif">
|
|
15
15
|
<img src="docs/media/replay-light.gif" width="1100"
|
|
16
|
-
alt="The tarmac map with the scrubber dragged
|
|
16
|
+
alt="The tarmac map, live and then replayed. It opens on the live fleet, whose nodes are grouped into a frame per working directory, each labelled with its project. As the scrubber is dragged the frames give way to the flat record — sessions drawn as dials, each arc a context window, background agents as strips of text — with the account's five-hour and seven-day gauges above them. Sessions appear and disappear, the arcs fill and reset, the five-hour window fills and rolls over, and a banner names the minute being replayed until Back to live is pressed and the frames come back.">
|
|
17
17
|
</picture>
|
|
18
18
|
|
|
19
19
|
[Quickstart](#quickstart) · [The map](#the-map) · [Install](#install) ·
|
|
@@ -58,30 +58,45 @@ Node ≥ 20. **Zero runtime dependencies** — no framework, no bundler, nothing
|
|
|
58
58
|
|
|
59
59
|
`tarmac serve` puts the same fleet in the browser — every session a row, ages that keep
|
|
60
60
|
climbing, and a banner the moment a refresh fails instead of a table quietly going stale. It
|
|
61
|
-
binds to loopback and refuses any request whose `Host` is not loopback, or that a browser
|
|
62
|
-
|
|
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:
|
|
63
64
|
[the manual](docs/MANUAL.md#what-serve-listens-on).
|
|
64
65
|
|
|
65
66
|
<picture>
|
|
66
67
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
|
|
67
68
|
<img src="docs/media/map-light.png" width="1100"
|
|
68
|
-
alt="The tarmac map, live.
|
|
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.">
|
|
69
70
|
</picture>
|
|
70
71
|
|
|
71
72
|
The tab in the header swaps the table for the same fleet as nodes — one per session, the arc
|
|
72
|
-
its context, the shape by the name its state.
|
|
73
|
-
|
|
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
|
|
74
85
|
[gauges](docs/MANUAL.md#the-accounts-two-windows); under it a **scrubber over the day this
|
|
75
|
-
serve has seen** — drag it and the
|
|
76
|
-
the fleet as it was at that minute, press play and the day walks past.
|
|
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.
|
|
77
91
|
|
|
78
92
|
The rules the table follows, the map follows: a reading past the freshness threshold is drawn
|
|
79
93
|
thin, amber and dated, a percentage nobody measured is an empty dotted dial rather than a ring
|
|
80
|
-
at zero, and
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
the
|
|
84
|
-
|
|
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).
|
|
85
100
|
|
|
86
101
|
## Install
|
|
87
102
|
|
|
@@ -128,7 +143,7 @@ deliberately not configurable, and all of it works with no configuration at all.
|
|
|
128
143
|
|---|---|---|
|
|
129
144
|
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
|
|
130
145
|
| port | `--port 8080` | `4477` |
|
|
131
|
-
| snapshots dir (read side) | `--snapshots-dir DIR` | the
|
|
146
|
+
| snapshots dir (read side) | `--snapshots-dir DIR` | the wrapper's frozen path when installed, else the XDG state directory |
|
|
132
147
|
|
|
133
148
|
Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
|
|
134
149
|
**Flag beats environment beats config file beats default**, settled per setting; `serve`
|
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
|
@@ -607,22 +607,86 @@ export function renderPage(fleet, view = 'table') {
|
|
|
607
607
|
/* ── the map ─────────────────────────────────────────────────────────────────────────
|
|
608
608
|
One node per session. The arc is the context, its weight is how much that reading may
|
|
609
609
|
be believed, and the halo — the only thing on this page that moves — says a frame
|
|
610
|
-
landed moments ago.
|
|
611
|
-
|
|
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; }
|
|
612
646
|
.node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
|
|
613
647
|
display:flex; flex-direction:column; align-items:center; text-align:center; }
|
|
614
648
|
.node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
|
|
615
649
|
.node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
|
|
616
|
-
/* An agent is a smaller
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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; }
|
|
626
690
|
/* Said, not shown: the three glyphs differ in silhouette, so a reader who cannot separate
|
|
627
691
|
two hues still has the state — but a screen reader is handed a bullet and nothing else. */
|
|
628
692
|
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
|
@@ -674,8 +738,12 @@ export function renderPage(fleet, view = 'table') {
|
|
|
674
738
|
.why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
|
|
675
739
|
.why b { display:block; font-size:1.25rem; font-weight:400; }
|
|
676
740
|
.who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
|
|
677
|
-
|
|
678
|
-
|
|
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; }
|
|
679
747
|
.shape { font-size:.7rem; color:var(--dim); }
|
|
680
748
|
.node[data-state="busy"] .shape { color:var(--busy); }
|
|
681
749
|
.node[data-state="waiting"] .shape { color:var(--wait); }
|
|
@@ -686,7 +754,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
686
754
|
.sub.waiting-for { color:var(--wait); font-weight:600; }
|
|
687
755
|
.asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
|
|
688
756
|
.asof.stale { color:var(--warn); font-weight:600; }
|
|
689
|
-
@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; } }
|
|
690
758
|
|
|
691
759
|
/* Below this the table stops being a table: one card per session, every value keeping the
|
|
692
760
|
name of the column it came from. Nothing is dropped — a phone that hides the context
|
|
@@ -702,6 +770,24 @@ export function renderPage(fleet, view = 'table') {
|
|
|
702
770
|
tr[data-state="busy"] { border-left-color:var(--busy); }
|
|
703
771
|
tr[data-state="waiting"] { border-left-color:var(--wait); }
|
|
704
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; }
|
|
705
791
|
td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
|
|
706
792
|
display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
|
|
707
793
|
td::before { content:attr(data-label); color:var(--dim); font-size:.72rem; font-weight:600;
|
|
@@ -759,7 +845,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
759
845
|
would be a claim about nothing. -->
|
|
760
846
|
<div class="limits" id="replay-limits" role="group" aria-label="account rate limits, at the minute being replayed" hidden></div>
|
|
761
847
|
<div class="meta" id="replay-meta"></div>
|
|
762
|
-
<div class="map" id="replay-map"></div>
|
|
848
|
+
<div class="map flat" id="replay-map"></div>
|
|
763
849
|
</div>
|
|
764
850
|
<!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
|
|
765
851
|
what it says it covers is whatever the record answered with. -->
|
|
@@ -978,7 +1064,18 @@ function pageScript(view) {
|
|
|
978
1064
|
// A gap that says it is a gap is not a gap. The handle steps through readings, not
|
|
979
1065
|
// through minutes, and a record with holes in it is not a smooth walk.
|
|
980
1066
|
+ (record.missed ? ', ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
|
|
981
|
-
+ '. 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.';
|
|
982
1079
|
}
|
|
983
1080
|
|
|
984
1081
|
function ready() {
|
|
@@ -1040,6 +1137,26 @@ function pageScript(view) {
|
|
|
1040
1137
|
// unescaped, and into the glyph slot as a function body.
|
|
1041
1138
|
var state = own(SHAPE, x.state) ? x.state : 'unknown';
|
|
1042
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
|
+
}
|
|
1043
1160
|
// The ring keeps each reading and never how old that reading was, so the arc weight that
|
|
1044
1161
|
// says how much a reading may be believed cannot be earned here. It is not the live
|
|
1045
1162
|
// default either: this third value is de-weighted in the stylesheet, and never the warning
|
|
@@ -1144,9 +1261,11 @@ function pageScript(view) {
|
|
|
1144
1261
|
function nodesOf(s) {
|
|
1145
1262
|
var anchored = false, html = '', i;
|
|
1146
1263
|
for (i = 0; i < s.sessions.length; i++) if (s.sessions[i].kind === INTERACTIVE) anchored = true;
|
|
1147
|
-
// In the order the sample carries. The live map
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
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.
|
|
1150
1269
|
for (i = 0; i < s.sessions.length; i++) html += nodeOf(s.sessions[i], anchored);
|
|
1151
1270
|
return html;
|
|
1152
1271
|
}
|
|
@@ -1300,21 +1419,45 @@ const stateLabel = (state, r) => state === 'waiting' && r.waitingFor ? `${stateW
|
|
|
1300
1419
|
*/
|
|
1301
1420
|
const CTX_WHY = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' };
|
|
1302
1421
|
/**
|
|
1303
|
-
* The map: one node per session,
|
|
1304
|
-
* its business — `renderLive` says that once, above both views, rather than
|
|
1305
|
-
* 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.
|
|
1306
1425
|
*
|
|
1307
1426
|
* There are no edges because the sources publish no relationship between two sessions — the
|
|
1308
|
-
* one thing they do carry is the working directory, and that is
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
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.
|
|
1311
1430
|
*
|
|
1312
1431
|
* Everything a reader interprets is decided in `map.ts` and rendered here, on the server,
|
|
1313
1432
|
* for the same reason the table is: the rules that keep a reading honest are tested, and a
|
|
1314
1433
|
* copy of them re-derived in browser JavaScript would sit where this suite cannot reach.
|
|
1315
1434
|
*/
|
|
1316
1435
|
export function renderMap(fleet) {
|
|
1317
|
-
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>`;
|
|
1318
1461
|
}
|
|
1319
1462
|
/**
|
|
1320
1463
|
* One node. Five facts, in five channels that do not depend on colour alone: the arc is how
|
|
@@ -1326,6 +1469,11 @@ export function renderMap(fleet) {
|
|
|
1326
1469
|
* One state brings a caption with it. A waiting session is the only one where the shape
|
|
1327
1470
|
* leaves a question the source can answer — which human answer it is halted on — and it is
|
|
1328
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.
|
|
1329
1477
|
*/
|
|
1330
1478
|
function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
1331
1479
|
// The model owns "is there a number"; this reads its verdict rather than asking the row a
|
|
@@ -1341,18 +1489,50 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
|
1341
1489
|
: reading === 'undated'
|
|
1342
1490
|
? `<div class="asof stale">! undated</div>`
|
|
1343
1491
|
: '';
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
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
|
+
}
|
|
1347
1528
|
return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
|
|
1348
1529
|
<div class="dial">
|
|
1349
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>
|
|
1350
1531
|
<div class="val">${value}</div>
|
|
1351
1532
|
${pulse ? `<span class="sr">a reading just landed</span>` : ''}
|
|
1352
1533
|
</div>
|
|
1353
|
-
<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>
|
|
1354
1535
|
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|
|
1355
|
-
<div class="sub">${esc(r.name)}</div>
|
|
1356
1536
|
${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
|
|
1357
1537
|
<div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
|
|
1358
1538
|
${asOf}
|