@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 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 is the account's, not any
49
- session's: 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, dated like every
51
- other reading here, and `— no reading` rather than `0%` when no snapshot carried one.
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`, model, effort and
55
- cost fall to `—`, and the line under the table counts how many sessions are covered.
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` works everywhere. An option handed to a command that does not read it is an error,
59
- not something quietly ignored. Every command, flag and route:
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: every session a row, ages that keep
65
- climbing, and a banner the moment a refresh fails instead of a table quietly going stale. It
66
- binds to loopback and refuses any request whose `Host` is not loopback, or that a browser
67
- marks as coming from another origin, so your cwd paths and costs never leave the machine. A
68
- client that sends no such mark, curl or a script, is left alone. Behind a reverse proxy, which
69
- presents a `Host` of its own, `--trust-host <name>` names the one to let through and says who
70
- that lets in: [the manual](docs/MANUAL.md#putting-it-behind-a-reverse-proxy). The listening
71
- rules: [the manual](docs/MANUAL.md#what-serve-listens-on).
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 tab in the header swaps the table for the same fleet as nodes: one per session, the arc
80
- its context, the shape by the name its state. A background agent has no terminal behind it to
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. That is the whole
86
- of what the frame claims: these were read in the same directory. It is not a parentage.
87
- `claude agents --json` publishes nothing that ties an agent to whoever dispatched it, so no
88
- label, no position and no line inside a frame says one node asked for another. An agent whose
89
- directory matches no session gets a frame of its own rather than somebody else's.
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); under it a scrubber over the day this
93
- serve has seen. Drag it and the nodes render the fleet as it was at that minute, press play
94
- and the day walks past. Replayed, they are drawn flat and unframed: the record keeps a project
95
- name and never the directory it was read in, and a basename is not a directory. Two checkouts
96
- of `atlas` answer to the same word, so a frame drawn on it would claim a shared directory
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 rather than a ring
101
- at zero. Where nothing was published at all, such as the strip of an agent the join found no
102
- snapshot for, nothing is drawn in its place, neither a dial nor a dash. A word
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; scripts pass `--yes`, deliberately.
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 kept
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 line is the honest caveat. The real defence is not immunity, it is visible
136
- degradation. A missing measurement is never a confident `0`. It is an em dash that names which
137
- kind of missing it is: `absent` for a session no status line ever wrote for, `fresh` for one
138
- that has taken no turn yet, `drift` for a release that moved the payload out from under us. A
139
- stale reading keeps its value and gets dated with a `!`. A Claude Code build no fixture covers
140
- gets its version named *before* anything breaks, and tarmac keeps reporting. The full
141
- state-by-state table is in [the manual](docs/MANUAL.md#degradation-state-by-state).
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
- Three numbers are opinions rather than truths, so all three are yours, and behind a reverse
146
- proxy the hosts `serve` answers to are yours as well. Everything else is deliberately not
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 loopback only |
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; `serve` opens by
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: [the manual](docs/MANUAL.md#configuration).
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. What it will not do is guess at the rest: a session that asked you a question in prose
168
- still reports `idle`, and the only way to know better is to read a transcript, which is
169
- the one thing this tool will not do. The signal is as good as its source, and no better.
170
- - **No history on disk.** `tarmac list` is a snapshot in time. A running `serve` holds the
171
- last 24 hours of the readings it took itself, in memory, so the page can replay them. That
172
- record reaches no further back than the serve that took it, and goes when it goes. None of
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, because a screenshot of a
186
- real machine carries working directories, prompts and costs, and nothing real enters this
187
- repo. What CI covers, which Node version develops and which one ships:
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 four settings can also be set, in decreasing order of precedence, by the
51
- environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR, TARMAC_TRUST_HOST)
52
- and by <home>/.claude/tarmac/config.json ({"staleAfterMs": …, "port": …,
53
- "snapshotsDir": …, "trustHosts": […]}). \`serve\` prints which one won.
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
- // The one place the CLI deletes anything: temp files its own wrapper left behind when a
122
- // terminal died mid-write. Best effort, and it says what it did rather than doing it
123
- // quietly this is the user's directory.
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
  /**