@adrrr/tarmac 0.2.0 → 0.4.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/dist/schema.js CHANGED
@@ -30,7 +30,7 @@
30
30
  */
31
31
  export const CHECKED_VERSIONS = {
32
32
  statusline: ['2.1.220', '2.1.226'],
33
- agents: ['2.1.226'],
33
+ agents: ['2.1.226', '2.1.232'],
34
34
  };
35
35
  const SURFACE_LABEL = {
36
36
  statusline: 'statusline payload',
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import http from 'node:http';
6
6
  import { reason, renderLive, renderPage } from './render.js';
7
7
  import { SOURCE_PHRASE } from './config.js';
8
+ import { createHistory, HISTORY_CADENCE_MS } from './history.js';
8
9
  /**
9
10
  * On every answer, including the refusals and the 500s. The page swaps what this port returns
10
11
  * into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
@@ -14,8 +15,41 @@ import { SOURCE_PHRASE } from './config.js';
14
15
  * failures carry it too: their text is what it quotes as the reason.
15
16
  */
16
17
  const IDENTITY = { 'x-tarmac': '1' };
17
- export function createFleetServer({ collect }) {
18
- return http.createServer(async (req, res) => {
18
+ /** The addresses that serve the shell, and which view each one opens on. */
19
+ const PAGES = new Map([
20
+ ['/', 'table'],
21
+ ['/map', 'map'],
22
+ ]);
23
+ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS }) {
24
+ // What this serve has already read, kept for a day and never written down. `since` is the
25
+ // moment this server was made, not the first sample that landed: the span it covers is how
26
+ // long the process has been up, and an hour of it with nothing in it is a fact worth
27
+ // showing rather than an empty record pretending to be a young one.
28
+ const history = createHistory({ since: Date.now(), cadence: sampleEveryMs });
29
+ // One at a time. `claude agents --json` has a 15s deadline of its own, and a fleet slower
30
+ // than a slot would otherwise be answered with a queue of processes instead of one missed
31
+ // minute — the tick that finds a read still running counts the slot and stands down.
32
+ let reading = false;
33
+ const sample = async () => {
34
+ if (reading) {
35
+ history.miss(Date.now());
36
+ return;
37
+ }
38
+ reading = true;
39
+ try {
40
+ history.record(await collect());
41
+ }
42
+ catch {
43
+ // A collector that throws is the normal weather here: `claude` missing, a laptop that
44
+ // was asleep. It costs a slot and nothing else — a throw out of this timer would be an
45
+ // unhandled rejection, and `serve` runs unattended for hours.
46
+ history.miss(Date.now());
47
+ }
48
+ finally {
49
+ reading = false;
50
+ }
51
+ };
52
+ const server = http.createServer(async (req, res) => {
19
53
  // Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
20
54
  // from reading /api/fleet — which carries cwd paths, session ids and costs.
21
55
  if (!isLoopbackHost(req.headers.host)) {
@@ -38,11 +72,35 @@ export function createFleetServer({ collect }) {
38
72
  const url = new URL(req.url, 'http://localhost');
39
73
  // `/live` is what the open page asks for every few seconds: the same render as `/`, minus
40
74
  // the shell. Serving the whole page there would hand the running script a copy of itself.
41
- if (url.pathname !== '/' && url.pathname !== '/live' && url.pathname !== '/api/fleet') {
75
+ //
76
+ // `/map` is the same page opened on the other view, and deliberately not `/?view=map`:
77
+ // the tabs are plain links, so the view has to be somewhere a reload and a bookmark can
78
+ // both find it. There is no second fragment — one `/live` carries both views, which is
79
+ // what keeps them from ever showing readings of different ages.
80
+ if (!PAGES.has(url.pathname) &&
81
+ url.pathname !== '/live' &&
82
+ url.pathname !== '/api/fleet' &&
83
+ url.pathname !== '/api/history') {
42
84
  res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
43
85
  res.end('not found\n');
44
86
  return;
45
87
  }
88
+ // Served out of the ring, above the collect below and never through it: this route is
89
+ // what the serve has ALREADY read, and one that collected would let a scrubber spawn
90
+ // `claude agents --json` on every drag of its handle.
91
+ if (url.pathname === '/api/history') {
92
+ res.writeHead(200, {
93
+ ...IDENTITY,
94
+ 'content-type': 'application/json; charset=utf-8',
95
+ 'cache-control': 'no-store',
96
+ });
97
+ // Not indented, alone among the JSON answers here. `/api/fleet` pretty-prints ONE
98
+ // reading, which a person reads in a terminal; this is up to 1440 of them, where the
99
+ // indentation is 40% of a body no human will ever open — megabytes of whitespace on
100
+ // every poll of the replay.
101
+ res.end(JSON.stringify(history.read()));
102
+ return;
103
+ }
46
104
  // Read AND render inside the guard, and send nothing until there is something to send.
47
105
  // Writing the 200 first and rendering after made the collector's failure a 500 and the
48
106
  // renderer's failure a dead daemon: the headers were already on the wire, the throw
@@ -58,7 +116,7 @@ export function createFleetServer({ collect }) {
58
116
  }
59
117
  else {
60
118
  type = 'text/html; charset=utf-8';
61
- body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet);
119
+ body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet, PAGES.get(url.pathname));
62
120
  }
63
121
  }
64
122
  catch (e) {
@@ -72,6 +130,29 @@ export function createFleetServer({ collect }) {
72
130
  res.writeHead(200, { ...IDENTITY, 'content-type': type, 'cache-control': 'no-store' });
73
131
  res.end(body);
74
132
  });
133
+ // The sampler lives exactly as long as the serving does. Started at construction it kept
134
+ // reading the fleet for a server whose `listen` had refused — a port named on the command
135
+ // line and taken — into a ring no request could ever reach; and never cleared, it did the
136
+ // same for every server a suite had closed behind it.
137
+ //
138
+ // `close` is the only shutdown this module has. `tarmac serve` itself has no graceful one:
139
+ // Ctrl-C ends the process, which takes the timer with it. Unref'ing is the belt to that
140
+ // braces — it keeps the sampler from being a reason `node --test`, or anything else that
141
+ // embeds this server, stays alive — and it is deliberately not the thing relied on.
142
+ let sampler = null;
143
+ server.on('listening', () => {
144
+ if (sampler !== null)
145
+ return;
146
+ sampler = setInterval(() => void sample(), sampleEveryMs);
147
+ sampler.unref();
148
+ });
149
+ server.on('close', () => {
150
+ if (sampler === null)
151
+ return;
152
+ clearInterval(sampler);
153
+ sampler = null;
154
+ });
155
+ return server;
75
156
  }
76
157
  /**
77
158
  * How far past a port NOBODY CHOSE `serve` may walk. A corridor, not a search: wide enough
package/dist/sessions.js CHANGED
@@ -6,10 +6,36 @@
6
6
  // Design rule carried over from the fleet's "3rd blindness": a status we do not recognise
7
7
  // is `null`, never `false`. A release that renames `busy` must make Tarmac say "I don't
8
8
  // know", not "everything is calm" — the second is a silent outage, the first is a signal.
9
+ /**
10
+ * What each word this surface prints says about the one question the boolean asks: is this
11
+ * session working. A word that does not answer it is absent, and absent means `null` — "we do
12
+ * not know" — which is the whole point of the file.
13
+ *
14
+ * The first two arrive on a session with a process of its own; the other two are a background
15
+ * agent's `state`, which is where its word lives instead.
16
+ *
17
+ * Some words are left out ON PURPOSE rather than for want of a payload. `failed` and `stopped`
18
+ * are "not working", and that is the least interesting true thing about them; `blocked` and
19
+ * `waiting` are a session halted until a human answers something, where `false` reads as calm
20
+ * on a session that needs you and `true` as fine on one that has stopped. Unknown is the only
21
+ * bucket whose node prints the word itself, so those keep it: an amber node captioned `failed`
22
+ * says what neither boolean could.
23
+ */
9
24
  const KNOWN_STATUS = new Map([
10
25
  ['busy', true],
11
26
  ['idle', false],
27
+ ['working', true],
28
+ ['done', false],
12
29
  ]);
30
+ /**
31
+ * The word for a session halted until a human answers, and the only one with a field saying
32
+ * which answer. It stays out of the map above — the boolean it would have to fill still has
33
+ * no honest value — but it is no longer counted as a word we failed to recognise: it has a
34
+ * state of its own on both surfaces, and a reason to print beside it.
35
+ */
36
+ const WAITING = 'waiting';
37
+ /** Whether this reading is halted on a human. The one status the renderers treat as a state. */
38
+ export const isWaiting = (s) => s.status === WAITING;
13
39
  /** @param text raw stdout of `claude agents --json` */
14
40
  export function parseAgents(text) {
15
41
  let raw;
@@ -32,9 +58,16 @@ export function parseAgents(text) {
32
58
  const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
33
59
  if (!sessionId)
34
60
  health.noSessionId += 1;
35
- const status = typeof entry.status === 'string' ? entry.status : null;
61
+ // A background agent carries no `status` at all its word is under `state`. `status`
62
+ // still wins where both are present: it comes from the agent's own process, and `state`
63
+ // is what the dispatcher believes about an agent whose process may not be on this machine.
64
+ const status = typeof entry.status === 'string' ? entry.status : typeof entry.state === 'string' ? entry.state : null;
36
65
  const busy = KNOWN_STATUS.has(status) ? KNOWN_STATUS.get(status) : null;
37
- if (busy === null)
66
+ // Two different questions, and only the second one is a blind spot: `busy` is null here
67
+ // for a word we cannot answer the boolean with, and `waiting` is one of those — while
68
+ // being a word this tool knows by name. Counting it would put "reports a status tarmac
69
+ // does not know" on the banner over a session tarmac is drawing, captioned, as waiting.
70
+ if (busy === null && status !== WAITING)
38
71
  health.unknownStatus += 1;
39
72
  sessions.push({
40
73
  sessionId,
@@ -44,6 +77,7 @@ export function parseAgents(text) {
44
77
  kind: typeof entry.kind === 'string' ? entry.kind : null,
45
78
  startedAt: typeof entry.startedAt === 'number' ? entry.startedAt : null,
46
79
  status,
80
+ waitingFor: typeof entry.waitingFor === 'string' ? entry.waitingFor : null,
47
81
  busy,
48
82
  });
49
83
  }
package/dist/snapshots.js CHANGED
@@ -102,8 +102,25 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
102
102
  mtimeMs = fs.statSync(file).mtimeMs;
103
103
  payload = JSON.parse(fs.readFileSync(file, 'utf8'));
104
104
  }
105
- catch {
106
- unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
105
+ catch (e) {
106
+ // ENOENT: a name listed a moment ago that resolves to nothing now. Almost always the
107
+ // sweep, deleting a cold snapshot out of the very directory we are reading — its job,
108
+ // and a race with our own housekeeping rather than a payload we failed to parse.
109
+ // Counting it made tarmac drive its own format-drift warning (up to 2675 phantom
110
+ // unreadable on one read of a 20k directory, and `list --watch` and `serve` redraw
111
+ // often enough to be inside that window).
112
+ //
113
+ // The cost, said out loud: `statSync` follows symlinks, so a DANGLING one named like a
114
+ // snapshot is ENOENT too, and it goes silent forever — a permanent state skipped as if
115
+ // it were a passing race. Deliberate. There is no payload behind a dead link either,
116
+ // and telling the two apart (an `lstat` first) buys a warning about a file `ls` already
117
+ // shows. Note it is the opposite call from `reap.ts:75`, which lstats PRECISELY so a
118
+ // dead link is not ENOENT: it deletes, and `unlink` takes a link away just fine. Reader
119
+ // and reaper ask different questions of the same shape.
120
+ //
121
+ // ENOENT only. A file we were not ALLOWED to open still counts, and must.
122
+ if (e.code !== 'ENOENT')
123
+ unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
107
124
  continue;
108
125
  }
109
126
  const t = extractTelemetry(payload);
package/dist/wrapper.js CHANGED
@@ -16,13 +16,16 @@
16
16
  // below is in the POSIX shell command language, and `test/portability.test.ts` runs this
17
17
  // script under every POSIX shell present on the machine to keep it that way.
18
18
  //
19
- // Two invariants, both tested by running the real script:
19
+ // Three invariants, all tested by running the real script:
20
20
  // RULE 1 — never break the display. Missing chain, failing chain, unwritable directory:
21
21
  // the status line still renders and the exit code is still 0. Telemetry loses,
22
22
  // display wins, always.
23
23
  // RULE 2 — never write outside the snapshot directory. `session_id` is external input
24
24
  // that becomes a filename, so anything that is not UUID-shaped is REFUSED, not
25
25
  // sanitised: a guessed name would be read back later as if it were certain.
26
+ // RULE 3 — the sweep may remove exactly what the writer may write, no more and no less.
27
+ // One rule, `SID_GLOB`, read by both — because when those two sets are merely
28
+ // written to agree, they stop agreeing quietly, in both directions at once (#7).
26
29
  /**
27
30
  * Prefix of every temp file the wrapper writes, and the ONLY thing that proves tarmac
28
31
  * wrote one. `.<sid>.<pid>.tmp` — what this used to emit — is a convention, not a
@@ -55,21 +58,54 @@ export const PRUNE_EVERY_MIN = 60;
55
58
  */
56
59
  export const SNAPSHOT_TTL_MIN = 48 * 60;
57
60
  /**
58
- * The names the sweep below is allowed to remove: the 8-4-4-4-12 session id Claude Code
59
- * emits, which is every fixture here and every file the live fleet directory holds. NOT
60
- * `*.json` that would take a `settings.json` or a `fleet.json` sitting next to them, data
61
- * this script never wrote, deleted from inside a status line.
61
+ * A session id ONE rule, and the only one the writer below, the sweep below and the
62
+ * TypeScript that reads this directory are allowed to know. Written as a shell pattern
63
+ * because two of the three consumers are shell; the third derives its regex from it.
62
64
  *
63
- * The wrapper WRITES ids wider than this on purpose (it refuses to guess what an id may look
64
- * like), so such a file is written and never pruned. That is the only direction this trade
65
- * may fail in.
65
+ * It is the canonical UUID, 8-4-4-4-12 hex: every fixture in this repo, every file in the
66
+ * snapshot directory of the fleet this was built for, every transcript file observed. The
67
+ * statusline payload documents `session_id` only as a "unique session identifier", so that
68
+ * is an observation and not a promise — and the direction of the bet is deliberate. An id
69
+ * that is not a UUID is refused at write time, which surfaces as a live session with
70
+ * `absent` telemetry: a state `fleet.ts` already names and shows. The other bet — file
71
+ * whatever arrives, and widen the deleters to match — would put every stem of 8..64
72
+ * characters of `[0-9A-Za-z-]` within reach of `rm`, in a directory whose location comes
73
+ * from `XDG_STATE_HOME` and can therefore be `~/.claude` itself, where the legacy purge
74
+ * already deletes and where people keep a git repository. A missing row is recoverable.
75
+ *
76
+ * Bracket expressions, not `?`: `?` matches a leading dot (fnmatch without FNM_PERIOD), so
77
+ * the old glob reached dotfiles the writer's own charset forbids it to produce (#7).
78
+ *
79
+ * And an ENUMERATION, not the range `[0-9a-fA-F]`: a range is collated by the locale, which
80
+ * for a status line is whatever the TUI that spawned it carries. Under `en_US.UTF-8` — the
81
+ * ordinary case on macOS, where `/bin/sh` is bash — `a-f` reaches `é`, `ç` and fullwidth `a`
82
+ * in bash, in ksh and in BSD `find`, while the regex below is ASCII code points and always
83
+ * will be. That is one string meaning two different sets depending on `LANG`, which is this
84
+ * whole rule undone: a sid filed in a Terminal, refused under `LC_ALL=C`, and a file written
85
+ * by the first frame that no TypeScript consumer here can ever recognise. Sixteen digits
86
+ * spelled out roughly doubles the pattern — 356 characters to 772 — and costs nothing
87
+ * measurable per frame.
66
88
  */
67
- export const SNAPSHOT_GLOB = '????????-????-????-????-????????????.json';
89
+ const HEX = '[0123456789abcdefABCDEF]';
90
+ export const SID_GLOB = [8, 4, 4, 4, 12].map((n) => HEX.repeat(n)).join('-');
68
91
  /**
69
- * The same set, in Node derived from the glob itself so the shell that deletes and the
70
- * TypeScript that deletes can never drift apart. `?` is any single character, `.` is literal.
92
+ * The names the sweep below is allowed to remove: the sid rule, and a `.json`. NOT `*.json`
93
+ * that would take a `settings.json` or a `fleet.json` sitting next to them, data this script
94
+ * never wrote, deleted from inside a status line.
71
95
  */
72
- export const SNAPSHOT_NAME = new RegExp(`^${SNAPSHOT_GLOB.replace(/\./g, '\\.').replace(/\?/g, '.')}$`);
96
+ export const SNAPSHOT_GLOB = `${SID_GLOB}.json`;
97
+ /**
98
+ * The same rule, in Node — the pattern goes in RAW, because a bracket expression means the
99
+ * same set in both languages and `-` is literal in both. Only the extension is spelled twice,
100
+ * once per language, which is the one thing a translation could get wrong and so the one
101
+ * thing that is written out rather than derived.
102
+ *
103
+ * `SID_NAME` is the sid alone: `fleet.ts` asks it whether a LIVE session's id is one the
104
+ * wrapper would ever file, which is the difference between "no frame drawn yet" and "no frame
105
+ * will ever help" — two states that look identical on a row.
106
+ */
107
+ export const SID_NAME = new RegExp(`^${SID_GLOB}$`);
108
+ export const SNAPSHOT_NAME = new RegExp(`^${SID_GLOB}\\.json$`);
73
109
  /** Single-quotes a string for POSIX sh. */
74
110
  function shQuote(s) {
75
111
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -108,16 +144,15 @@ case "$payload" in
108
144
  esac
109
145
  ;;
110
146
  esac
111
- # refuse anything that is not UUID-shaped — this value becomes a filename
147
+ # Refuse anything that is not a session id — this value becomes a filename, and it is the
148
+ # same rule the sweep below deletes by: what this line declines to write, that one cannot
149
+ # unlink, and the reverse. An empty sid matches nothing here, so it stays empty.
112
150
  case "$sid" in
113
- ''|*[!0-9a-zA-Z-]*) sid='' ;;
151
+ ${SID_GLOB}) ;;
152
+ *) sid='' ;;
114
153
  esac
115
154
  # refuse an ambiguous payload outright
116
155
  [ "$payload_has_two_ids" = 1 ] && sid=''
117
- if [ -n "$sid" ]; then
118
- len=\${#sid}
119
- if [ "$len" -lt 8 ] || [ "$len" -gt 64 ]; then sid=''; fi
120
- fi
121
156
 
122
157
  # --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
123
158
  if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
@@ -149,6 +184,53 @@ fi
149
184
  # frame. (A directory where the stamp itself keeps failing does pay one \`touch\` per frame,
150
185
  # forever — a fork, not a directory walk, and it means nothing can be written there anyway.)
151
186
  #
187
+ # And DETACHED, because amortized is an average and the average was never the problem: on an
188
+ # install that has never pruned, the one frame that sweeps pays for the entire backlog at
189
+ # once — a directory walk plus ten thousand unlinks on 20 000 snapshots, measured at 0.5 s in
190
+ # the report and at 0.6-0.9 s by \`test/sweep-perf.test.ts\`, in front of the status line (#8).
191
+ # Bounding the work per sweep instead would only spread that cost, at one bounded batch an
192
+ # hour, over weeks of frames that each still stop to walk the
193
+ # same directory; the frame has no business waiting for any of it. So the sweep is handed to a
194
+ # child and the frame goes on to the chain: the frame's cost becomes one fork, whatever the
195
+ # directory holds, and NOTHING on the nominal path — a frame with no sweep due — changes at all.
196
+ # What that costs, all of it on the sweep's side of the fork:
197
+ # • the child outlives the frame. It is orphaned when the shell exits and reaped by init;
198
+ # nothing waits for it, so no zombie can accumulate in the TUI's process tree.
199
+ # • if the session dies mid-sweep, the sweep normally finishes on its own: it holds no state
200
+ # but the marker, already stamped, and an orphan is not interrupted by the death of its
201
+ # parent. It does NOT go away with a Ctrl-C either — POSIX has the shell set SIGINT and
202
+ # SIGQUIT to ignored in an asynchronous list where job control is off, which is here. Only
203
+ # a signal aimed at the process group (a SIGHUP or a SIGTERM from whatever supervises the
204
+ # TUI) takes it, and then what is left is what the next hour's sweep will find, which is
205
+ # where it was heading anyway.
206
+ # • the redirections are not hygiene, they are the point. A child that inherits the frame's
207
+ # stdout puts whatever it prints INTO the status line, and holds the pipe open after this
208
+ # shell has exited — the reader waits on EOF, so the frame would still block, having only
209
+ # moved where. \`</dev/null\` is the belt to those braces: POSIX already hands an
210
+ # asynchronous list \`/dev/null\` for stdin wherever job control is off — every shell that
211
+ # will ever run this — and stdin was drained by \`\$(cat)\` at the top anyway.
212
+ # • two frames that reach the marker check together both sweep, as they always could. The
213
+ # window is not the \`touch\` — it is the \`find\` that reads the marker's age, a whole
214
+ # process, which is why two frames drawn simultaneously fork two sweeps almost every time.
215
+ # Detaching improves that case rather than widening it: what used to be two frozen frames
216
+ # is now two detached walks. What matters is that a frame drawn AFTER a sweep has started
217
+ # sees a stamped marker and starts nothing, which is the ordinary case and the reason the
218
+ # stamping stayed in the frame rather than moving into the child. Across HOURS they can
219
+ # overlap — a sweep slower than the window is joined by the
220
+ # next one — and that is harmless: \`rm -f\` on a name another sweep already unlinked is
221
+ # not an error, and the marker is the only state either of them writes.
222
+ # • a \`find\` that HANGS (a stale network mount, a directory that never answers) is no longer
223
+ # one frozen frame; it is one orphaned process per hour that nothing here reaps. The trade
224
+ # is deliberate — a frozen frame breaks RULE 1, an idle process does not — but it is
225
+ # unbounded over time in a way the blocking shape was not.
226
+ # • the chain now runs BESIDE the sweep instead of after it, so anything reading this same
227
+ # directory can watch a file vanish under it mid-frame. Nothing here breaks on that — the
228
+ # display is untouched and no data is lost — but it is not free either: \`snapshots.ts\`
229
+ # counts a file that disappears between its \`readdir\` and its \`readFile\` as UNREADABLE,
230
+ # and \`list\`/\`serve\` report that as "the schema may have moved". Same window on the
231
+ # blocking shape (a sweep and a reader have always been able to overlap), so this is not
232
+ # a regression, and it is the READER's to fix: #17.
233
+ #
152
234
  # Two known holes, both of them the safe way round, and both inherited from the fleet script
153
235
  # this transposes:
154
236
  # • a marker dated in the FUTURE (clock skew, a restored backup, a network mount) is never
@@ -189,7 +271,7 @@ if [ -d "$TARMAC_DIR" ]; then
189
271
  # The glob is the sid SHAPE, not \`*.json\`, and that is the same rule \`reap.ts\` states
190
272
  # for the temp files: only what we wrote — see SNAPSHOT_GLOB, which the legacy purge in
191
273
  # \`install.ts\` reads from the same constant.
192
- find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + 2>/dev/null
274
+ find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + >/dev/null 2>&1 </dev/null &
193
275
  fi
194
276
  fi
195
277
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",
@@ -36,7 +36,7 @@
36
36
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
37
37
  "build": "npm run clean && tsc -p tsconfig.build.json",
38
38
  "typecheck": "tsc -p tsconfig.json",
39
- "test": "npm run typecheck && node --test \"test/*.test.ts\"",
39
+ "test": "npm run typecheck && node --test --test-timeout=120000 --test-force-exit \"test/*.test.ts\"",
40
40
  "fixtures:capture": "node scripts/capture-fixtures.ts",
41
41
  "prepack": "npm run build",
42
42
  "prepublishOnly": "npm run build && TARMAC_REQUIRE_DASH=1 npm test && node dist/cli.js --help > /dev/null"