@adrrr/tarmac 0.7.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 +47 -10
- package/dist/args.js +9 -3
- package/dist/cli.js +62 -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 +27 -7
- 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
|
|
|
@@ -79,10 +79,11 @@ listening rules are [there too](docs/MANUAL.md#what-serve-listens-on).
|
|
|
79
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.">
|
|
80
80
|
</picture>
|
|
81
81
|
|
|
82
|
-
The
|
|
82
|
+
The tabs in the header swap the table for the same fleet drawn as nodes: one per session, the
|
|
83
83
|
arc its context, the shape by the name its state. A background agent has no terminal behind it
|
|
84
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.
|
|
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.
|
|
86
87
|
|
|
87
88
|
The nodes are grouped by working directory: a frame per directory, labelled with its project,
|
|
88
89
|
the sessions inside it as cards and the agents docked underneath as strips. A frame claims one
|
|
@@ -108,6 +109,26 @@ filed as `idle`, and raises one warning above the fleet naming it. The page is J
|
|
|
108
109
|
underneath: `GET /api/fleet` and `GET /api/history` serve exactly what the map draws. Details
|
|
109
110
|
are in [the manual](docs/MANUAL.md#the-map).
|
|
110
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).
|
|
131
|
+
|
|
111
132
|
## Install
|
|
112
133
|
|
|
113
134
|
`install` changes the `statusLine` key of `~/.claude/settings.json`, and never on your
|
|
@@ -146,9 +167,9 @@ and tarmac keeps reporting. The full state-by-state table is in
|
|
|
146
167
|
|
|
147
168
|
## Configuration
|
|
148
169
|
|
|
149
|
-
|
|
150
|
-
proxy
|
|
151
|
-
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.
|
|
152
173
|
|
|
153
174
|
| Setting | Flag | Default |
|
|
154
175
|
|---|---|---|
|
|
@@ -156,6 +177,7 @@ configurable, and all of it works with no configuration at all.
|
|
|
156
177
|
| port | `--port 8080` | `4477` |
|
|
157
178
|
| snapshots dir (read side) | `--snapshots-dir DIR` | the wrapper's frozen path when installed, else the XDG state directory |
|
|
158
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 |
|
|
159
181
|
|
|
160
182
|
Each also has an environment variable and a key in `<home>/.claude/tarmac/config.json`.
|
|
161
183
|
Flag beats environment beats config file beats default, settled per setting. `serve` opens by
|
|
@@ -171,10 +193,25 @@ and what would have worked. Spellings, edge cases and the two health fields
|
|
|
171
193
|
the map. It does not guess at the rest. A session that asked you a question in prose still
|
|
172
194
|
reports `idle`, because the only way to know better is to read a transcript, and this tool
|
|
173
195
|
does not read transcripts.
|
|
174
|
-
- **
|
|
175
|
-
last 24 hours of the readings it took itself, in memory, so the page can
|
|
176
|
-
record reaches no further back than the serve that took it, and goes when
|
|
177
|
-
it is written down
|
|
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.
|
|
178
215
|
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
179
216
|
- **No remote fleets.** It watches the machine it runs on.
|
|
180
217
|
|
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,6 +13,7 @@ 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',
|
|
@@ -39,7 +40,7 @@ const LISTS = new Set(['trustHost']);
|
|
|
39
40
|
*/
|
|
40
41
|
const ACCEPTS = {
|
|
41
42
|
list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help', 'version']),
|
|
42
|
-
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'home', 'claudeBin', 'help', 'version']),
|
|
43
|
+
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'historyDays', 'home', 'claudeBin', 'help', 'version']),
|
|
43
44
|
install: new Set(['home', 'yes', 'help', 'version']),
|
|
44
45
|
uninstall: new Set(['home', 'yes', 'help', 'version']),
|
|
45
46
|
help: new Set(['help', 'version']),
|
|
@@ -60,7 +61,7 @@ export function accepts(command, flag) {
|
|
|
60
61
|
return key !== undefined && ACCEPTS[command].has(key);
|
|
61
62
|
}
|
|
62
63
|
export function parseArgs(argv) {
|
|
63
|
-
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false, version: 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 };
|
|
64
65
|
let i = 0;
|
|
65
66
|
if (argv[0] && !argv[0].startsWith('-')) {
|
|
66
67
|
if (!COMMANDS.has(argv[0]))
|
|
@@ -90,6 +91,11 @@ export function parseArgs(argv) {
|
|
|
90
91
|
// in the same words wherever it was set.
|
|
91
92
|
out.port = parsePort(value, '--port');
|
|
92
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
|
+
}
|
|
93
99
|
else if (LISTS.has(key)) {
|
|
94
100
|
// Same rule, for the same reason — and the value is kept as the guard will compare it,
|
|
95
101
|
// so what `serve` prints on startup is what it will actually match against.
|
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { createFleetServer, listenFleetServer } from './server.js';
|
|
|
17
17
|
import { install, uninstall, paths, planInstall, planUninstall, installedSnapshotsDir, wrapperIsOurs } from './install.js';
|
|
18
18
|
import { confirmTyped } from './prompt.js';
|
|
19
19
|
import { reapOrphanedTemps } from './reap.js';
|
|
20
|
+
import { acquireJournalLock, createHistoryStore, historyDirFor } from './history-store.js';
|
|
20
21
|
import { renderPlan, renderSettings, renderTable, restoreMeaning, servingLine } from './render.js';
|
|
21
22
|
import { runWatch } from './watch.js';
|
|
22
23
|
const USAGE = `tarmac — fleet observability for Claude Code
|
|
@@ -25,13 +26,14 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
25
26
|
[--claude-bin PATH] [--json] [--watch]
|
|
26
27
|
one-shot fleet table — with --watch, redrawn every 5s until ^C
|
|
27
28
|
tarmac serve [--home DIR] [--port N] [--stale-after D] [--snapshots-dir DIR]
|
|
28
|
-
[--claude-bin PATH] [--trust-host HOST]
|
|
29
|
+
[--claude-bin PATH] [--trust-host HOST] [--history-days N]
|
|
29
30
|
local dashboard
|
|
30
31
|
tarmac install [--home DIR] [--yes]
|
|
31
32
|
chain the statusline
|
|
32
33
|
tarmac uninstall [--home DIR] [--yes]
|
|
33
34
|
restore the statusline exactly
|
|
34
35
|
|
|
36
|
+
--help print this text and exit — on any command
|
|
35
37
|
--version, -v print the version of this build and exit — on any command
|
|
36
38
|
--watch redraw the table every 5s until ^C, dating every reading
|
|
37
39
|
--home whose .claude to read or change (default: this home)
|
|
@@ -48,11 +50,17 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
48
50
|
once per host (default: none). For a reverse proxy: give the name
|
|
49
51
|
your browser shows, without the port, and remember that whoever can
|
|
50
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
|
|
51
58
|
|
|
52
|
-
Those
|
|
53
|
-
environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR, TARMAC_TRUST_HOST
|
|
54
|
-
and by <home>/.claude/tarmac/config.json ({"staleAfterMs": …,
|
|
55
|
-
"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.
|
|
56
64
|
`;
|
|
57
65
|
/**
|
|
58
66
|
* What this build calls itself, read from the `package.json` that ships beside it — the one
|
|
@@ -130,7 +138,7 @@ try {
|
|
|
130
138
|
if (frozen === null && wrapperIsOurs(p))
|
|
131
139
|
console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
|
|
132
140
|
const config = resolveConfig({
|
|
133
|
-
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 },
|
|
134
142
|
env: process.env,
|
|
135
143
|
file: readConfigFile(p.config),
|
|
136
144
|
// The installed wrapper's own path, when there is one: the default is where the
|
|
@@ -141,11 +149,51 @@ try {
|
|
|
141
149
|
const snapshotsDir = config.snapshotsDir.value;
|
|
142
150
|
const staleAfterMs = config.staleAfterMs.value;
|
|
143
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);
|
|
144
155
|
// Unattended for hours, so it opens by saying what it decided and on whose authority.
|
|
145
|
-
process.stdout.write(renderSettings(config, p.config));
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
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.
|
|
149
197
|
const { reaped, failed } = reapOrphanedTemps(snapshotsDir);
|
|
150
198
|
if (reaped > 0)
|
|
151
199
|
console.log(`tarmac: reaped ${reaped} orphaned snapshot temp file(s)`);
|
|
@@ -154,6 +202,10 @@ try {
|
|
|
154
202
|
const server = createFleetServer({
|
|
155
203
|
collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
|
|
156
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 }),
|
|
157
209
|
});
|
|
158
210
|
// A port nobody chose is not worth failing over: this walks past a busy 4477 and says
|
|
159
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
|
/**
|