@adrrr/tarmac 0.1.2 → 0.3.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/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
@@ -54,6 +57,55 @@ export const PRUNE_EVERY_MIN = 60;
54
57
  * so nothing was reading that file as current anyway.
55
58
  */
56
59
  export const SNAPSHOT_TTL_MIN = 48 * 60;
60
+ /**
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.
64
+ *
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.
88
+ */
89
+ const HEX = '[0123456789abcdefABCDEF]';
90
+ export const SID_GLOB = [8, 4, 4, 4, 12].map((n) => HEX.repeat(n)).join('-');
91
+ /**
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.
95
+ */
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$`);
57
109
  /** Single-quotes a string for POSIX sh. */
58
110
  function shQuote(s) {
59
111
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -92,16 +144,15 @@ case "$payload" in
92
144
  esac
93
145
  ;;
94
146
  esac
95
- # 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.
96
150
  case "$sid" in
97
- ''|*[!0-9a-zA-Z-]*) sid='' ;;
151
+ ${SID_GLOB}) ;;
152
+ *) sid='' ;;
98
153
  esac
99
154
  # refuse an ambiguous payload outright
100
155
  [ "$payload_has_two_ids" = 1 ] && sid=''
101
- if [ -n "$sid" ]; then
102
- len=\${#sid}
103
- if [ "$len" -lt 8 ] || [ "$len" -gt 64 ]; then sid=''; fi
104
- fi
105
156
 
106
157
  # --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
107
158
  if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
@@ -133,6 +184,53 @@ fi
133
184
  # frame. (A directory where the stamp itself keeps failing does pay one \`touch\` per frame,
134
185
  # forever — a fork, not a directory walk, and it means nothing can be written there anyway.)
135
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
+ #
136
234
  # Two known holes, both of them the safe way round, and both inherited from the fleet script
137
235
  # this transposes:
138
236
  # • a marker dated in the FUTURE (clock skew, a restored backup, a network mount) is never
@@ -171,13 +269,9 @@ if [ -d "$TARMAC_DIR" ]; then
171
269
  # keeps a directory or a symlink wearing a session id's name out of it.
172
270
  #
173
271
  # The glob is the sid SHAPE, not \`*.json\`, and that is the same rule \`reap.ts\` states
174
- # for the temp files: only what we wrote. A bare \`*.json\` would take \`settings.json\`
175
- # or \`fleet.json\` with it — data this script never wrote, deleted from inside a status
176
- # line. 8-4-4-4-12 is the UUID Claude Code emits (every fixture here, and the live
177
- # fleet directory). The wrapper accepts wider ids than that because it refuses to guess
178
- # what an id may look like; a sid outside this shape is therefore written and never
179
- # pruned, which is the direction this trade has to fail in.
180
- find "$TARMAC_DIR"/. ! -name . -prune -name '????????-????-????-????-????????????.json' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + 2>/dev/null
272
+ # for the temp files: only what we wrote see SNAPSHOT_GLOB, which the legacy purge in
273
+ # \`install.ts\` reads from the same constant.
274
+ find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + >/dev/null 2>&1 </dev/null &
181
275
  fi
182
276
  fi
183
277
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",
@@ -14,6 +14,14 @@
14
14
  ],
15
15
  "license": "MIT",
16
16
  "author": "Adrien Leboeuf",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/adrrr/tarmac.git"
20
+ },
21
+ "homepage": "https://github.com/adrrr/tarmac#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/adrrr/tarmac/issues"
24
+ },
17
25
  "type": "module",
18
26
  "engines": {
19
27
  "node": ">=20"