@adrrr/tarmac 0.6.0 → 0.8.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 +103 -62
- package/dist/args.js +21 -9
- package/dist/cli.js +87 -10
- package/dist/config.js +49 -1
- package/dist/history-range.js +320 -0
- package/dist/history-store.js +381 -0
- package/dist/history-view.js +1264 -0
- package/dist/history.js +1 -0
- package/dist/reap.js +6 -0
- package/dist/render.js +57 -16
- package/dist/server.js +121 -3
- package/dist/wrapper.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ documented surfaces only, never an internal format.
|
|
|
16
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
|
-
[Quickstart](#quickstart) · [The map](#the-map) · [Install](#install) ·
|
|
19
|
+
[Quickstart](#quickstart) · [The map](#the-map) · [The curves](#the-curves) · [Install](#install) ·
|
|
20
20
|
[Configuration](#configuration) · [Commands](docs/MANUAL.md#commands-and-options) ·
|
|
21
21
|
[Manual](docs/MANUAL.md) · [Changelog](CHANGELOG.md) · [Issues](https://github.com/adrrr/tarmac/issues)
|
|
22
22
|
|
|
@@ -45,30 +45,33 @@ atlas idle — fresh 8h ! Opus 5 high $0.00 8h
|
|
|
45
45
|
account 5h 17% resets in 2h 14m · 7d 42% resets in 3d 11h · as of 7m
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
That is the table with the status line chained. The last line
|
|
49
|
-
session
|
|
50
|
-
[window](docs/MANUAL.md#the-accounts-two-windows), so it is printed once
|
|
51
|
-
other reading here, and `— no reading` rather than `0%` when no snapshot
|
|
48
|
+
That is the table with the status line chained. The last line belongs to the account, not to
|
|
49
|
+
any one session. Every row above spends from the same five-hour and seven-day
|
|
50
|
+
[window](docs/MANUAL.md#the-accounts-two-windows), so it is printed once. It is dated like
|
|
51
|
+
every other reading here, and it reads `— no reading` rather than `0%` when no snapshot
|
|
52
|
+
carried one.
|
|
52
53
|
|
|
53
54
|
With nothing installed, the same command still lists every session, its state and its uptime,
|
|
54
|
-
straight from `claude agents --json`. The context column reads `— absent
|
|
55
|
-
cost fall to
|
|
55
|
+
straight from `claude agents --json`. The context column reads `— absent`. Model, effort and
|
|
56
|
+
cost fall to `—`. The line under the table counts how many sessions are covered.
|
|
56
57
|
|
|
57
58
|
Node ≥ 20. Zero runtime dependencies: no framework, no bundler, nothing to audit.
|
|
58
|
-
`--help`
|
|
59
|
-
|
|
59
|
+
`--help` and `--version` (`-v`) work everywhere. An option handed to a command that does not
|
|
60
|
+
read it is an error, never something quietly ignored. Every command, flag and route is in
|
|
60
61
|
[the manual](docs/MANUAL.md#commands-and-options).
|
|
61
62
|
|
|
62
63
|
## The map
|
|
63
64
|
|
|
64
|
-
`tarmac serve` puts the same fleet in the browser
|
|
65
|
-
climbing, and a banner the moment a refresh fails
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
that
|
|
71
|
-
|
|
65
|
+
`tarmac serve` puts the same fleet in the browser. Every session is a row, the ages keep
|
|
66
|
+
climbing, and a banner goes up the moment a refresh fails, so the table never goes stale in
|
|
67
|
+
silence.
|
|
68
|
+
|
|
69
|
+
It binds to loopback. It refuses any request whose `Host` is not loopback, and any request a
|
|
70
|
+
browser marks as coming from another origin, so your cwd paths and costs never leave the
|
|
71
|
+
machine. A client that sends no such mark, curl or a script, is left alone. A reverse proxy
|
|
72
|
+
presents a `Host` of its own, so `--trust-host <name>` names the one to let through;
|
|
73
|
+
[the manual](docs/MANUAL.md#putting-it-behind-a-reverse-proxy) says who that lets in. The
|
|
74
|
+
listening rules are [there too](docs/MANUAL.md#what-serve-listens-on).
|
|
72
75
|
|
|
73
76
|
<picture>
|
|
74
77
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
|
|
@@ -76,47 +79,68 @@ rules: [the manual](docs/MANUAL.md#what-serve-listens-on).
|
|
|
76
79
|
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.">
|
|
77
80
|
</picture>
|
|
78
81
|
|
|
79
|
-
The
|
|
80
|
-
its context, the shape by the name its state. A background agent has no terminal behind it
|
|
81
|
-
draw a frame with, so it gets a strip of text rather than a dial that could never fill. Both
|
|
82
|
-
views render the same reading into the same fragment, so they can never disagree.
|
|
82
|
+
The tabs in the header swap the table for the same fleet drawn as nodes: one per session, the
|
|
83
|
+
arc its context, the shape by the name its state. A background agent has no terminal behind it
|
|
84
|
+
to draw a frame with, so it gets a strip of text rather than a dial that could never fill. Both
|
|
85
|
+
views render the same reading into the same fragment, so they can never disagree. A third tab
|
|
86
|
+
draws the record rather than the reading: see [the curves](#the-curves) below.
|
|
83
87
|
|
|
84
88
|
The nodes are grouped by working directory: a frame per directory, labelled with its project,
|
|
85
|
-
the sessions inside it as cards and the agents docked underneath as strips.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
the sessions inside it as cards and the agents docked underneath as strips. A frame claims one
|
|
90
|
+
thing and no more: these nodes were read in the same directory. `claude agents --json`
|
|
91
|
+
publishes nothing that ties an agent to whoever dispatched it, so no label, no position and no
|
|
92
|
+
line inside a frame says one node asked for another. An agent whose directory matches no
|
|
93
|
+
session gets its own frame rather than somebody else's.
|
|
90
94
|
|
|
91
95
|
Above the fleet are the account's five-hour and seven-day
|
|
92
|
-
[gauges](docs/MANUAL.md#the-accounts-two-windows)
|
|
93
|
-
serve has seen. Drag it and the nodes render the fleet as it was at that minute
|
|
94
|
-
and the day walks past. Replayed,
|
|
95
|
-
name and never the directory it was read in, and a basename is not a directory. Two
|
|
96
|
-
of `atlas` answer to the same word, so a frame drawn on it would claim a shared
|
|
97
|
-
nobody can check.
|
|
96
|
+
[gauges](docs/MANUAL.md#the-accounts-two-windows). Under it is a scrubber over the day this
|
|
97
|
+
serve has seen. Drag it and the nodes render the fleet as it was at that minute. Press play
|
|
98
|
+
and the day walks past. Replayed, the nodes are drawn flat and unframed. The record keeps a
|
|
99
|
+
project name and never the directory it was read in, and a basename is not a directory. Two
|
|
100
|
+
checkouts of `atlas` answer to the same word, so a frame drawn on it would claim a shared
|
|
101
|
+
directory nobody can check.
|
|
98
102
|
|
|
99
103
|
The map follows the rules the table follows. A reading past the freshness threshold is drawn
|
|
100
|
-
thin, amber and dated. A percentage nobody measured is an empty dotted dial
|
|
101
|
-
|
|
102
|
-
snapshot for, nothing is drawn in its place
|
|
104
|
+
thin, amber and dated. A percentage nobody measured is an empty dotted dial, never a ring at
|
|
105
|
+
zero. Where nothing was published at all, such as the strip of an agent the join found no
|
|
106
|
+
snapshot for, nothing is drawn in its place: no dial, no dash. A word
|
|
103
107
|
`claude agents --json` printed that tarmac has no boolean for is shown as it came rather than
|
|
104
108
|
filed as `idle`, and raises one warning above the fleet naming it. The page is JSON
|
|
105
109
|
underneath: `GET /api/fleet` and `GET /api/history` serve exactly what the map draws. Details
|
|
106
|
-
in [the manual](docs/MANUAL.md#the-map).
|
|
110
|
+
are in [the manual](docs/MANUAL.md#the-map).
|
|
111
|
+
|
|
112
|
+
## The curves
|
|
113
|
+
|
|
114
|
+
The third tab draws what moved rather than what is: context per session, cost per project, and
|
|
115
|
+
the account's two windows, over the last 24 hours out of the ring or over 7 and 30 days out of
|
|
116
|
+
the journal, if you keep one. `<canvas>` and the page's own script, no library.
|
|
117
|
+
|
|
118
|
+
A context line breaks where its session did, so a recycle at three in the morning reads as a
|
|
119
|
+
break and not as a cliff, and a minute nobody read is a hole rather than a fall to zero. The
|
|
120
|
+
lines that gained fifteen points or more in the last three hours are drawn full and labelled,
|
|
121
|
+
which is the question the chart exists for: what has to be recycled tonight. Cost is stacked
|
|
122
|
+
bars, built in a fixed order so a project keeps its place in the column all week, with the
|
|
123
|
+
legend sorted by what each one spent. Quota is the seven-day line over the five-hour window,
|
|
124
|
+
and a turnover the serve slept through is drawn faint and marked `≈` rather than as a firm line
|
|
125
|
+
through a minute nobody measured.
|
|
126
|
+
|
|
127
|
+
No hover: a tap puts a cursor on a chart and every number on it becomes that moment's, with
|
|
128
|
+
`Back to now` to undo it. Without `history.days` the two longer ranges are greyed out and the
|
|
129
|
+
view says which key turns them on. Details are in
|
|
130
|
+
[the manual](docs/MANUAL.md#the-curves).
|
|
107
131
|
|
|
108
132
|
## Install
|
|
109
133
|
|
|
110
134
|
`install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
|
|
111
135
|
say-so alone. It prints the whole plan first, including the exact command that undoes it,
|
|
112
|
-
then waits for a typed word. `y` is not an answer
|
|
136
|
+
then waits for a typed word. `y` is not an answer. Scripts pass `--yes`, deliberately.
|
|
113
137
|
|
|
114
138
|
A status line you already had is wrapped, not replaced: its display stays byte-identical, and
|
|
115
139
|
`uninstall` names which of its four restore modes ran. Two files land under `~/.claude/`, the
|
|
116
140
|
wrapper and the `backup.json` that undoes it, and neither changes at runtime. The snapshots go
|
|
117
141
|
to `~/.local/state/tarmac/snapshots` (`$XDG_STATE_HOME` when set), because `~/.claude` is a
|
|
118
|
-
directory people commit. The plan, the restore modes and the cleanup of the layout that
|
|
119
|
-
snapshots inside `.claude
|
|
142
|
+
directory people commit. The plan, the restore modes and the cleanup of the old layout that
|
|
143
|
+
kept snapshots inside `.claude` are in
|
|
120
144
|
[the manual](docs/MANUAL.md#installing-safely-the-full-contract).
|
|
121
145
|
|
|
122
146
|
## Why it does not break
|
|
@@ -132,45 +156,62 @@ tarmac reads two things instead:
|
|
|
132
156
|
| `claude agents --json` | which sessions exist, busy, idle or waiting on you, cwd, uptime | a documented CLI surface (`--help`: *"Print active sessions … as a JSON array … for scripting"*) |
|
|
133
157
|
| the status line payload | context %, model, effort, cost | the JSON Claude Code hands to your own `statusLine.command` on every frame. Observed, not published as a schema |
|
|
134
158
|
|
|
135
|
-
That second
|
|
136
|
-
|
|
137
|
-
kind of missing it is: `absent` for a session no status
|
|
138
|
-
that has taken no turn yet, `drift` for a release that
|
|
139
|
-
stale reading keeps its value and gets dated with a
|
|
140
|
-
gets its version named *before* anything breaks,
|
|
141
|
-
state-by-state table is in
|
|
159
|
+
That second row is the honest caveat. tarmac does not promise the payload will hold still.
|
|
160
|
+
It promises that a measurement it could not take never renders as a confident `0`. What you
|
|
161
|
+
get instead is an em dash naming which kind of missing it is: `absent` for a session no status
|
|
162
|
+
line ever wrote for, `fresh` for one that has taken no turn yet, `drift` for a release that
|
|
163
|
+
moved the payload out from under us. A stale reading keeps its value and gets dated with a
|
|
164
|
+
`!`. A Claude Code build no fixture covers gets its version named *before* anything breaks,
|
|
165
|
+
and tarmac keeps reporting. The full state-by-state table is in
|
|
166
|
+
[the manual](docs/MANUAL.md#degradation-state-by-state).
|
|
142
167
|
|
|
143
168
|
## Configuration
|
|
144
169
|
|
|
145
|
-
|
|
146
|
-
proxy
|
|
147
|
-
configurable, and all of it works with no configuration at all.
|
|
170
|
+
The settings below are judgement calls, so they are yours: three numbers, the hosts `serve`
|
|
171
|
+
answers to behind a reverse proxy, and whether it keeps a journal on disk. Everything else is
|
|
172
|
+
deliberately not configurable, and all of it works with no configuration at all.
|
|
148
173
|
|
|
149
174
|
| Setting | Flag | Default |
|
|
150
175
|
|---|---|---|
|
|
151
176
|
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `10m` |
|
|
152
177
|
| port | `--port 8080` | `4477` |
|
|
153
178
|
| snapshots dir (read side) | `--snapshots-dir DIR` | the wrapper's frozen path when installed, else the XDG state directory |
|
|
154
|
-
| trusted hosts | `--trust-host HOST`, once per host | none
|
|
179
|
+
| trusted hosts | `--trust-host HOST`, once per host | none, so loopback only |
|
|
180
|
+
| journal retention | `--history-days 30`, on `serve` | none, so nothing is written to disk |
|
|
155
181
|
|
|
156
182
|
Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
|
|
157
|
-
Flag beats environment beats config file beats default, settled per setting
|
|
183
|
+
Flag beats environment beats config file beats default, settled per setting. `serve` opens by
|
|
158
184
|
printing each effective value and where it came from. Nothing is silently dropped or silently
|
|
159
185
|
corrected. A value that will not parse stops the run and says what it got, where it came from,
|
|
160
186
|
and what would have worked. Spellings, edge cases and the two health fields
|
|
161
|
-
`tarmac list --json` reports
|
|
187
|
+
`tarmac list --json` reports are in [the manual](docs/MANUAL.md#configuration).
|
|
162
188
|
|
|
163
189
|
## What it deliberately does not do
|
|
164
190
|
|
|
165
191
|
- **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason, such as
|
|
166
192
|
a permission prompt or an open dialog, and tarmac draws exactly that, in the table and on
|
|
167
|
-
the map.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
- **
|
|
171
|
-
last 24 hours of the readings it took itself, in memory, so the page can
|
|
172
|
-
record reaches no further back than the serve that took it, and goes when
|
|
173
|
-
it is written down
|
|
193
|
+
the map. It does not guess at the rest. A session that asked you a question in prose still
|
|
194
|
+
reports `idle`, because the only way to know better is to read a transcript, and this tool
|
|
195
|
+
does not read transcripts.
|
|
196
|
+
- **Nothing on disk unless you ask for it.** `tarmac list` is a snapshot in time. A running
|
|
197
|
+
`serve` holds the last 24 hours of the readings it took itself, in memory, so the page can
|
|
198
|
+
replay them. That record reaches no further back than the serve that took it, and goes when
|
|
199
|
+
it goes. Nothing of it is written down, and that is what every install that never asked
|
|
200
|
+
otherwise does.
|
|
201
|
+
|
|
202
|
+
Set `history.days` and `serve` also appends one JSON line a minute to
|
|
203
|
+
`~/.local/state/tarmac/history/YYYY-MM-DD.jsonl`, beside the snapshots. The line is the same
|
|
204
|
+
record the page replays, minus one field: per session, the session id, the project basename,
|
|
205
|
+
the kind, the state, the context percentage and the cost, plus the account's rate limits. No
|
|
206
|
+
session name, no working directory, and not the reason a waiting session gave, which is free
|
|
207
|
+
text off `claude agents --json` and does not belong in a file that outlives the process.
|
|
208
|
+
About 2 MB a day at eight sessions. Files older than the retention you set are removed when
|
|
209
|
+
`serve` starts and once a day after that, and writing stops altogether at 256 MB, which is
|
|
210
|
+
not a number you can raise. `serve` names the retention, the cap and the directory in its
|
|
211
|
+
settings block, before it serves anything. `GET /api/history?range=7d`, or `30d`, reads those
|
|
212
|
+
files back aggregated by hour and by project, where the same route with no range is the last
|
|
213
|
+
24 hours out of memory, and the third tab of the page draws both. To stop it, unset the
|
|
214
|
+
retention wherever you set it; to erase what is there, remove the directory.
|
|
174
215
|
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
175
216
|
- **No remote fleets.** It watches the machine it runs on.
|
|
176
217
|
|
|
@@ -182,9 +223,9 @@ npm run build # flat JavaScript into dist/
|
|
|
182
223
|
node scripts/demo-fleet.ts # the invented fleet the captures above are taken of
|
|
183
224
|
```
|
|
184
225
|
|
|
185
|
-
Every capture on this page is taken of a fleet that does not exist
|
|
186
|
-
|
|
187
|
-
|
|
226
|
+
Every capture on this page is taken of a fleet that does not exist. A screenshot of a real
|
|
227
|
+
machine carries working directories, prompts and costs, and nothing real enters this repo.
|
|
228
|
+
What CI covers, which Node version develops and which one ships:
|
|
188
229
|
[the manual](docs/MANUAL.md#developing). Capturing the fixtures for a new Claude Code build:
|
|
189
230
|
[the manual](docs/MANUAL.md#capturing-a-new-claude-code-version).
|
|
190
231
|
|
package/dist/args.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Unknown options and unknown commands are ERRORS. A typo silently ignored is how someone
|
|
5
5
|
// ends up believing they pointed tarmac at a directory it never read.
|
|
6
|
-
import { parsePort, parseTrustHost } from './config.js';
|
|
6
|
+
import { parseHistoryDays, parsePort, parseTrustHost } from './config.js';
|
|
7
7
|
const COMMANDS = new Set(['list', 'serve', 'install', 'uninstall', 'help']);
|
|
8
8
|
// `| undefined` is load-bearing, not decoration: without it the compiler types the lookup
|
|
9
9
|
// below as always-present and the `if (!key) throw` guard reads as dead code — which is how
|
|
@@ -13,14 +13,20 @@ const OPTIONS = {
|
|
|
13
13
|
'--stale-after': 'staleAfter',
|
|
14
14
|
'--snapshots-dir': 'snapshotsDir',
|
|
15
15
|
'--trust-host': 'trustHost',
|
|
16
|
+
'--history-days': 'historyDays',
|
|
16
17
|
'--home': 'home',
|
|
17
18
|
'--claude-bin': 'claudeBin',
|
|
18
19
|
'--json': 'json',
|
|
19
20
|
'--watch': 'watch',
|
|
20
21
|
'--yes': 'yes',
|
|
21
22
|
'--help': 'help',
|
|
23
|
+
'--version': 'version',
|
|
24
|
+
// The one short spelling this parser knows, and it is here because it is what people type
|
|
25
|
+
// before they read anything (#110). No other flag gets one: a letter is a scarce name, and
|
|
26
|
+
// `-h` for help would be the next request and the one after that.
|
|
27
|
+
'-v': 'version',
|
|
22
28
|
};
|
|
23
|
-
const FLAGS = new Set(['json', 'watch', 'yes', 'help']);
|
|
29
|
+
const FLAGS = new Set(['json', 'watch', 'yes', 'help', 'version']);
|
|
24
30
|
/** Options that ACCUMULATE rather than overwrite — passed twice, both values are kept. */
|
|
25
31
|
const LISTS = new Set(['trustHost']);
|
|
26
32
|
/**
|
|
@@ -29,14 +35,15 @@ const LISTS = new Set(['trustHost']);
|
|
|
29
35
|
* one level down — `tarmac serve --json` parsed cleanly and changed nothing, and from the
|
|
30
36
|
* outside that is indistinguishable from a server that decided to answer HTML anyway.
|
|
31
37
|
*
|
|
32
|
-
* `help` is in every set on purpose: `--help` is answerable whatever else was typed
|
|
38
|
+
* `help` is in every set on purpose: `--help` is answerable whatever else was typed, and
|
|
39
|
+
* `version` is there for the same reason — what build this is does not depend on the verb.
|
|
33
40
|
*/
|
|
34
41
|
const ACCEPTS = {
|
|
35
|
-
list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help']),
|
|
36
|
-
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'home', 'claudeBin', 'help']),
|
|
37
|
-
install: new Set(['home', 'yes', 'help']),
|
|
38
|
-
uninstall: new Set(['home', 'yes', 'help']),
|
|
39
|
-
help: new Set(['help']),
|
|
42
|
+
list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help', 'version']),
|
|
43
|
+
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'historyDays', 'home', 'claudeBin', 'help', 'version']),
|
|
44
|
+
install: new Set(['home', 'yes', 'help', 'version']),
|
|
45
|
+
uninstall: new Set(['home', 'yes', 'help', 'version']),
|
|
46
|
+
help: new Set(['help', 'version']),
|
|
40
47
|
};
|
|
41
48
|
/** The commands a misplaced flag would have been right on — an error that points somewhere. */
|
|
42
49
|
function ownersOf(key) {
|
|
@@ -54,7 +61,7 @@ export function accepts(command, flag) {
|
|
|
54
61
|
return key !== undefined && ACCEPTS[command].has(key);
|
|
55
62
|
}
|
|
56
63
|
export function parseArgs(argv) {
|
|
57
|
-
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false };
|
|
64
|
+
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], historyDays: null, home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false, version: false };
|
|
58
65
|
let i = 0;
|
|
59
66
|
if (argv[0] && !argv[0].startsWith('-')) {
|
|
60
67
|
if (!COMMANDS.has(argv[0]))
|
|
@@ -84,6 +91,11 @@ export function parseArgs(argv) {
|
|
|
84
91
|
// in the same words wherever it was set.
|
|
85
92
|
out.port = parsePort(value, '--port');
|
|
86
93
|
}
|
|
94
|
+
else if (key === 'historyDays') {
|
|
95
|
+
// Same validator the environment and the config file go through, so a retention is
|
|
96
|
+
// refused in the same words wherever it was set.
|
|
97
|
+
out.historyDays = parseHistoryDays(value, '--history-days');
|
|
98
|
+
}
|
|
87
99
|
else if (LISTS.has(key)) {
|
|
88
100
|
// Same rule, for the same reason — and the value is kept as the guard will compare it,
|
|
89
101
|
// so what `serve` prints on startup is what it will actually match against.
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// `install` / `uninstall` default to the home this process runs under, print what they are
|
|
9
9
|
// about to change, and proceed only on the typed verb (or `--yes`, in writing, for scripts).
|
|
10
|
+
import fs from 'node:fs';
|
|
10
11
|
import os from 'node:os';
|
|
11
12
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
12
13
|
import { parseArgs } from './args.js';
|
|
@@ -16,6 +17,7 @@ import { createFleetServer, listenFleetServer } from './server.js';
|
|
|
16
17
|
import { install, uninstall, paths, planInstall, planUninstall, installedSnapshotsDir, wrapperIsOurs } from './install.js';
|
|
17
18
|
import { confirmTyped } from './prompt.js';
|
|
18
19
|
import { reapOrphanedTemps } from './reap.js';
|
|
20
|
+
import { acquireJournalLock, createHistoryStore, historyDirFor } from './history-store.js';
|
|
19
21
|
import { renderPlan, renderSettings, renderTable, restoreMeaning, servingLine } from './render.js';
|
|
20
22
|
import { runWatch } from './watch.js';
|
|
21
23
|
const USAGE = `tarmac — fleet observability for Claude Code
|
|
@@ -24,13 +26,15 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
24
26
|
[--claude-bin PATH] [--json] [--watch]
|
|
25
27
|
one-shot fleet table — with --watch, redrawn every 5s until ^C
|
|
26
28
|
tarmac serve [--home DIR] [--port N] [--stale-after D] [--snapshots-dir DIR]
|
|
27
|
-
[--claude-bin PATH] [--trust-host HOST]
|
|
29
|
+
[--claude-bin PATH] [--trust-host HOST] [--history-days N]
|
|
28
30
|
local dashboard
|
|
29
31
|
tarmac install [--home DIR] [--yes]
|
|
30
32
|
chain the statusline
|
|
31
33
|
tarmac uninstall [--home DIR] [--yes]
|
|
32
34
|
restore the statusline exactly
|
|
33
35
|
|
|
36
|
+
--help print this text and exit — on any command
|
|
37
|
+
--version, -v print the version of this build and exit — on any command
|
|
34
38
|
--watch redraw the table every 5s until ^C, dating every reading
|
|
35
39
|
--home whose .claude to read or change (default: this home)
|
|
36
40
|
--yes skip the typed confirmation — required when stdin is not a terminal
|
|
@@ -46,12 +50,34 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
46
50
|
once per host (default: none). For a reverse proxy: give the name
|
|
47
51
|
your browser shows, without the port, and remember that whoever can
|
|
48
52
|
reach that name can read this fleet
|
|
53
|
+
--history-days keep a fleet journal for N days, in a directory beside the snapshots
|
|
54
|
+
(default: none, and nothing is written to disk). On \`serve\` only,
|
|
55
|
+
which is the command that samples: one JSON line a minute, no session
|
|
56
|
+
name and no working directory, about 2 MB a day at eight sessions,
|
|
57
|
+
and writing stops at 256 MB whatever N says
|
|
49
58
|
|
|
50
|
-
Those
|
|
51
|
-
environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR, TARMAC_TRUST_HOST
|
|
52
|
-
and by <home>/.claude/tarmac/config.json ({"staleAfterMs": …,
|
|
53
|
-
"snapshotsDir": …, "trustHosts": […]}). \`serve\`
|
|
59
|
+
Those five settings can also be set, in decreasing order of precedence, by the
|
|
60
|
+
environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR, TARMAC_TRUST_HOST,
|
|
61
|
+
TARMAC_HISTORY_DAYS) and by <home>/.claude/tarmac/config.json ({"staleAfterMs": …,
|
|
62
|
+
"port": …, "snapshotsDir": …, "trustHosts": […], "history": {"days": …}}). \`serve\`
|
|
63
|
+
prints which one won.
|
|
54
64
|
`;
|
|
65
|
+
/**
|
|
66
|
+
* What this build calls itself, read from the `package.json` that ships beside it — the one
|
|
67
|
+
* file npm puts in every tarball whatever `files` says, and the only place the number is
|
|
68
|
+
* written down. `dist/cli.js` sits one directory in exactly as `src/cli.ts` does, so a single
|
|
69
|
+
* relative path answers for the published CLI and for the suite, which runs the source.
|
|
70
|
+
*
|
|
71
|
+
* A version it could not read is not a version: rather than print an "unknown" nobody can act
|
|
72
|
+
* on, this throws and leaves through the same catch as every other refusal here.
|
|
73
|
+
*/
|
|
74
|
+
function version() {
|
|
75
|
+
const at = new URL('../package.json', import.meta.url);
|
|
76
|
+
const pkg = JSON.parse(fs.readFileSync(at, 'utf8'));
|
|
77
|
+
if (typeof pkg.version !== 'string' || pkg.version === '')
|
|
78
|
+
throw new Error(`${at.href} carries no version`);
|
|
79
|
+
return pkg.version;
|
|
80
|
+
}
|
|
55
81
|
try {
|
|
56
82
|
// Parsing is inside the try so that a refusal — a typo'd flag, a duration nobody can read,
|
|
57
83
|
// a config file with a key that does not exist — reaches the user as one line naming the
|
|
@@ -61,6 +87,13 @@ try {
|
|
|
61
87
|
process.stdout.write(USAGE);
|
|
62
88
|
process.exit(0);
|
|
63
89
|
}
|
|
90
|
+
// After `--help`, which answers the wider question, and before any command has begun: a
|
|
91
|
+
// `tarmac install --version` that printed a plan and then waited for a typed word would be
|
|
92
|
+
// a one-line question that hangs a script. Bare, so something other than a person can read it.
|
|
93
|
+
if (args.version) {
|
|
94
|
+
process.stdout.write(`${version()}\n`);
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
64
97
|
if (args.command === 'install' || args.command === 'uninstall') {
|
|
65
98
|
const home = args.home ?? os.homedir();
|
|
66
99
|
// The plan is computed first and printed whole: every refusal this operation has in it
|
|
@@ -105,7 +138,7 @@ try {
|
|
|
105
138
|
if (frozen === null && wrapperIsOurs(p))
|
|
106
139
|
console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
|
|
107
140
|
const config = resolveConfig({
|
|
108
|
-
flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir, trustHosts: args.trustHost },
|
|
141
|
+
flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir, trustHosts: args.trustHost, historyDays: args.historyDays },
|
|
109
142
|
env: process.env,
|
|
110
143
|
file: readConfigFile(p.config),
|
|
111
144
|
// The installed wrapper's own path, when there is one: the default is where the
|
|
@@ -116,11 +149,51 @@ try {
|
|
|
116
149
|
const snapshotsDir = config.snapshotsDir.value;
|
|
117
150
|
const staleAfterMs = config.staleAfterMs.value;
|
|
118
151
|
if (args.command === 'serve') {
|
|
152
|
+
// Beside the snapshots, never among them: `reap.ts`, the wrapper's own sweep and the
|
|
153
|
+
// legacy purge in `install.ts` all decide by name inside that directory.
|
|
154
|
+
const historyDir = historyDirFor(snapshotsDir);
|
|
119
155
|
// Unattended for hours, so it opens by saying what it decided and on whose authority.
|
|
120
|
-
process.stdout.write(renderSettings(config, p.config));
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
156
|
+
process.stdout.write(renderSettings(config, p.config, historyDir));
|
|
157
|
+
// One journal, one owner (#133). The retention is a property of the DIRECTORY and the
|
|
158
|
+
// process applying it was whichever `serve` started last, so a `--history-days 1` run to
|
|
159
|
+
// try the setting out swept the thirty days another serve was keeping. A second serve
|
|
160
|
+
// journals nothing now, and serves everything else exactly as it did.
|
|
161
|
+
const days = config.historyDays.value;
|
|
162
|
+
const { lock, heldBy } = days === null ? { lock: null, heldBy: null } : acquireJournalLock({ dir: historyDir });
|
|
163
|
+
// On stdout, under the settings block it corrects: that block has just named a retention
|
|
164
|
+
// and a directory, and a reader piping it must not be left holding the half of it that
|
|
165
|
+
// did not happen. Same reason `serve` says on stdout which port it walked to.
|
|
166
|
+
if (days !== null && lock === null) {
|
|
167
|
+
const why = heldBy === null ? `could not take the journal lock in ${historyDir}` : `pid ${heldBy} holds the journal in ${historyDir}`;
|
|
168
|
+
console.log(`tarmac: ${why}, so this serve keeps no journal`);
|
|
169
|
+
}
|
|
170
|
+
// Given back on the way out, signals included: Node's default for each of these ends the
|
|
171
|
+
// process without running an exit hook, so a Ctrl-C would leave the lock behind and the
|
|
172
|
+
// next serve would wait five minutes for its heartbeat to go quiet. SIGHUP is in the list
|
|
173
|
+
// because closing the terminal, or losing the ssh session, is how a foreground serve
|
|
174
|
+
// usually dies.
|
|
175
|
+
//
|
|
176
|
+
// Each handler releases and then re-raises, rather than exiting with a number: a serve
|
|
177
|
+
// that answered a supervisor `exited 143` where every other serve answers `killed by
|
|
178
|
+
// SIGTERM` would have made the journal visible to systemd and launchd, which know nothing
|
|
179
|
+
// about it. Nothing about how this process ends may depend on a file in a directory.
|
|
180
|
+
if (lock !== null) {
|
|
181
|
+
process.on('exit', () => lock.release());
|
|
182
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
183
|
+
// Removing THIS handler rather than every handler for the signal: the next hand to
|
|
184
|
+
// register one here would otherwise be dropped by ours, in silence.
|
|
185
|
+
const bye = () => {
|
|
186
|
+
lock.release();
|
|
187
|
+
process.off(signal, bye);
|
|
188
|
+
process.kill(process.pid, signal);
|
|
189
|
+
};
|
|
190
|
+
process.on(signal, bye);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Temp files its own wrapper left behind when a terminal died mid-write. Best effort, and
|
|
194
|
+
// it says what it did rather than doing it quietly, this being the user's directory. It
|
|
195
|
+
// is no longer the only deletion a `serve` makes: a journal that was asked for applies
|
|
196
|
+
// its retention on `listening`, and says that too. Nothing else here removes anything.
|
|
124
197
|
const { reaped, failed } = reapOrphanedTemps(snapshotsDir);
|
|
125
198
|
if (reaped > 0)
|
|
126
199
|
console.log(`tarmac: reaped ${reaped} orphaned snapshot temp file(s)`);
|
|
@@ -129,6 +202,10 @@ try {
|
|
|
129
202
|
const server = createFleetServer({
|
|
130
203
|
collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
|
|
131
204
|
trustedHosts: config.trustHosts.value,
|
|
205
|
+
// No key anywhere is no store, which is no directory and no file: the default is that
|
|
206
|
+
// nothing of this fleet is written down, and it is the default that is the product.
|
|
207
|
+
// No lock is no store either, and for the same reason: no store, no sweep, no line.
|
|
208
|
+
store: days === null || lock === null ? null : createHistoryStore({ dir: historyDir, days, lock }),
|
|
132
209
|
});
|
|
133
210
|
// A port nobody chose is not worth failing over: this walks past a busy 4477 and says
|
|
134
211
|
// where it landed. A port that WAS chosen refuses instead, and the refusal leaves
|
package/dist/config.js
CHANGED
|
@@ -65,6 +65,27 @@ function checkPort(n, label, shown = n) {
|
|
|
65
65
|
}
|
|
66
66
|
return n;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* How many local days of fleet journal to keep on disk, today included.
|
|
70
|
+
*
|
|
71
|
+
* The one setting here whose absence is the product: with no `history` key there is no journal,
|
|
72
|
+
* which is what every install that never asked for one gets. So ZERO is refused rather than read
|
|
73
|
+
* as "off". It is the number a reader reaches for to mean off, and reading it that way would
|
|
74
|
+
* leave a file growing all day under a retention that keeps none of it. Off is the absence of
|
|
75
|
+
* the key, which is also what deleting it does: one way to stop, not two.
|
|
76
|
+
*
|
|
77
|
+
* @param label how the source spells this setting, so the refusal names the knob to turn
|
|
78
|
+
*/
|
|
79
|
+
export function parseHistoryDays(text, label) {
|
|
80
|
+
const trimmed = text.trim();
|
|
81
|
+
return checkHistoryDays(/^\d+$/.test(trimmed) ? Number(trimmed) : NaN, label, text);
|
|
82
|
+
}
|
|
83
|
+
function checkHistoryDays(n, label, shown = n) {
|
|
84
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
|
|
85
|
+
throw new Error(`${label} must be a whole number of days, 1 or more, got: ${format(shown)}`);
|
|
86
|
+
}
|
|
87
|
+
return n;
|
|
88
|
+
}
|
|
68
89
|
/**
|
|
69
90
|
* The name out of a `Host` header, or out of a setting that has to match one: the port
|
|
70
91
|
* dropped, the brackets of an IPv6 literal dropped with it.
|
|
@@ -98,7 +119,7 @@ export function parseTrustHost(text, label) {
|
|
|
98
119
|
}
|
|
99
120
|
/** Letters, digits, dots and dashes, starting and ending on one that is not a dash or a dot. */
|
|
100
121
|
const HOST_NAME = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
101
|
-
const KNOWN_KEYS = ['staleAfterMs', 'port', 'snapshotsDir', 'trustHosts'];
|
|
122
|
+
const KNOWN_KEYS = ['staleAfterMs', 'port', 'snapshotsDir', 'trustHosts', 'history'];
|
|
102
123
|
/**
|
|
103
124
|
* `~/.claude/tarmac/config.json`, if there is one.
|
|
104
125
|
*
|
|
@@ -165,6 +186,23 @@ export function readConfigFile(file) {
|
|
|
165
186
|
return parseTrustHost(h, where('trustHosts'));
|
|
166
187
|
});
|
|
167
188
|
}
|
|
189
|
+
if ('history' in body) {
|
|
190
|
+
const v = body.history;
|
|
191
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
|
192
|
+
throw new Error(`${where('history')} must be an object like {"days": 30}, got: ${format(v)}`);
|
|
193
|
+
}
|
|
194
|
+
const inner = v;
|
|
195
|
+
const unknownInner = Object.keys(inner).filter((k) => k !== 'days');
|
|
196
|
+
if (unknownInner.length > 0) {
|
|
197
|
+
throw new Error(`unknown key(s) in ${file}: ${unknownInner.map((k) => `history.${k}`).join(', ')} (the only key of history is days)`);
|
|
198
|
+
}
|
|
199
|
+
// An unfinished key, not an off switch. `"history": {}` reads as a setting the tool took
|
|
200
|
+
// and silently never applied, which is the one thing this module exists to prevent.
|
|
201
|
+
if (!('days' in inner)) {
|
|
202
|
+
throw new Error(`${where('history.days')} must be set to a whole number of days; remove the history key to keep nothing on disk`);
|
|
203
|
+
}
|
|
204
|
+
out.history = { days: checkHistoryDays(inner.days, where('history.days')) };
|
|
205
|
+
}
|
|
168
206
|
return out;
|
|
169
207
|
}
|
|
170
208
|
/**
|
|
@@ -197,6 +235,7 @@ export function resolveConfig({ flags, env, file, defaultSnapshotsDir }) {
|
|
|
197
235
|
// Comma-separated, which a host name cannot contain — so the list needs no quoting rule of
|
|
198
236
|
// its own, and an empty item between two commas is refused rather than skipped.
|
|
199
237
|
const trustEnv = parseIfSet(env.TARMAC_TRUST_HOST, (v) => v.split(',').map((h) => parseTrustHost(h, 'TARMAC_TRUST_HOST')));
|
|
238
|
+
const historyEnv = parseIfSet(env.TARMAC_HISTORY_DAYS, (v) => parseHistoryDays(v, 'TARMAC_HISTORY_DAYS'));
|
|
200
239
|
return {
|
|
201
240
|
staleAfterMs: flags.staleAfter !== null
|
|
202
241
|
? { value: parseDuration(flags.staleAfter, '--stale-after'), source: 'flag' }
|
|
@@ -229,6 +268,15 @@ export function resolveConfig({ flags, env, file, defaultSnapshotsDir }) {
|
|
|
229
268
|
: file.trustHosts !== undefined
|
|
230
269
|
? { value: file.trustHosts, source: 'file' }
|
|
231
270
|
: { value: [], source: 'default' },
|
|
271
|
+
// The only default here that is not a number: no key anywhere means no journal, and that
|
|
272
|
+
// is the behaviour every install had before this setting existed.
|
|
273
|
+
historyDays: flags.historyDays !== null
|
|
274
|
+
? { value: flags.historyDays, source: 'flag' }
|
|
275
|
+
: historyEnv !== null
|
|
276
|
+
? { value: historyEnv, source: 'env' }
|
|
277
|
+
: file.history !== undefined
|
|
278
|
+
? { value: file.history.days, source: 'file' }
|
|
279
|
+
: { value: null, source: 'default' },
|
|
232
280
|
};
|
|
233
281
|
}
|
|
234
282
|
/**
|