@adrrr/tarmac 0.8.1 → 0.10.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
@@ -63,10 +63,19 @@ export function guardVersions(seen) {
63
63
  const state = noVersion > 0 ? 'no-version' : unchecked.length > 0 ? 'unchecked' : 'ok';
64
64
  return { state, versions, noVersion, unchecked };
65
65
  }
66
- /** What a human should be told, or `null` when there is nothing worth saying. */
66
+ /** What a human should be told, in the halves a page shows it in. `null` when there is nothing. */
67
+ export function schemaNoteParts(guard) {
68
+ const said = schemaSaid(guard);
69
+ return said.length === 0 ? null : { key: 'schema', lead: said[0], rest: said.slice(1).join(' ') };
70
+ }
71
+ /** The same notice, whole. What every caller outside the page still asks for. */
67
72
  export function schemaNotice(guard) {
73
+ const parts = schemaNoteParts(guard);
74
+ return parts === null ? null : `${parts.lead} ${parts.rest}`.trim();
75
+ }
76
+ function schemaSaid(guard) {
68
77
  if (guard.state === 'ok' || guard.state === 'nothing')
69
- return null;
78
+ return [];
70
79
  const said = [];
71
80
  if (guard.noVersion > 0) {
72
81
  const total = guard.noVersion + guard.versions.length;
@@ -83,5 +92,5 @@ export function schemaNotice(guard) {
83
92
  // and has no scripts/ directory. Capturing a fixture is a maintainer's move, documented
84
93
  // in the README, not something to send a user looking for.
85
94
  said.push(`Nothing is blocked and no reading is hidden; if a column starts coming up empty, update tarmac or report it at ${ISSUES_URL}.`);
86
- return said.join(' ');
95
+ return said;
87
96
  }
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import http from 'node:http';
6
6
  import { reason, renderLive, renderPage } from './render.js';
7
7
  import { hostName, SOURCE_PHRASE } from './config.js';
8
8
  import { createHistory, HISTORY_CADENCE_MS } from './history.js';
9
- import { HISTORY_RANGES, readRange } from './history-range.js';
9
+ import { HISTORY_RANGES } from './history-range.js';
10
10
  /**
11
11
  * On every answer, including the refusals and the 500s. The page swaps what this port returns
12
12
  * into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
@@ -16,13 +16,21 @@ import { HISTORY_RANGES, readRange } from './history-range.js';
16
16
  * failures carry it too: their text is what it quotes as the reason.
17
17
  */
18
18
  const IDENTITY = { 'x-tarmac': '1' };
19
+ /**
20
+ * A range read that never came back, kept apart from a range read that failed: one is a journal
21
+ * that answered with a broken file, the other a directory nothing came out of at all. The
22
+ * handler answers 500 for the first and 504 for the second, and a reader is sent to two
23
+ * different places by them.
24
+ */
25
+ class JournalTimeout extends Error {
26
+ }
19
27
  /** The addresses that serve the shell, and which view each one opens on. */
20
28
  const PAGES = new Map([
21
29
  ['/', 'table'],
22
30
  ['/map', 'map'],
23
31
  ['/history', 'history'],
24
32
  ]);
25
- export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [], store = null, rangeCacheMs = 60_000, report = (line) => console.error(line), }) {
33
+ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [], store = null, rangeCacheMs = 60_000, readJournal = (s, range, now) => s.read(range, now), rangeDeadlineMs = 30_000, report = (line) => console.error(line), history = createHistory({ since: Date.now(), cadence: sampleEveryMs }), demo = false, }) {
26
34
  // Normalised HERE rather than trusted to arrive that way. This is the last thing between a
27
35
  // foreign origin and the fleet, so it owns both sides of its own comparison — the config
28
36
  // parser cuts a name the same way, and neither leans on the other having done it.
@@ -33,17 +41,23 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
33
41
  // reachable from a flag, a variable or a file gets here empty; that is the parser's promise,
34
42
  // and this is the guard not resting on it.
35
43
  const trusted = new Set(trustedHosts.map((h) => hostName(h.trim()).toLowerCase()).filter((h) => h !== ''));
44
+ // The page wears a badge; this is the same fact for machines. The HTML shell is one of four
45
+ // surfaces this port answers, and the other three are JSON and a fragment a consumer may
46
+ // archive or forward — where an invented fleet with nothing on it saying so becomes a real
47
+ // one the moment the response body travels alone. On every answer for the same reason
48
+ // IDENTITY is: the refusals and the 500s of a demo serve are the demo's too.
49
+ const identity = demo ? { ...IDENTITY, 'x-tarmac-demo': '1' } : IDENTITY;
36
50
  // Which rule refused, decided once. With hosts named, "loopback hosts only" would read as a
37
51
  // flag that never took; with none, this is the sentence it has always been, to the byte. The
38
52
  // Host itself is never quoted back: it is the one string on the request the caller wrote.
39
53
  const refusal = trusted.size === 0
40
54
  ? 'tarmac serves loopback hosts only\n'
41
55
  : 'tarmac serves loopback and trusted hosts only\n';
42
- // What this serve has already read, kept for a day and never written down. `since` is the
43
- // moment this server was made, not the first sample that landed: the span it covers is how
44
- // long the process has been up, and an hour of it with nothing in it is a fact worth
45
- // showing rather than an empty record pretending to be a young one.
46
- const history = createHistory({ since: Date.now(), cadence: sampleEveryMs });
56
+ // What this serve has already read, kept for a day and never written down, is the ring above
57
+ // — its `since` is the moment it was made, not the first sample that landed, so the span it
58
+ // covers is how long the process has been up and an hour of it with nothing in it is a fact
59
+ // worth showing rather than an empty record pretending to be a young one.
60
+ //
47
61
  // One at a time. `claude agents --json` has a 15s deadline of its own, and a fleet slower
48
62
  // than a slot would otherwise be answered with a queue of processes instead of one missed
49
63
  // minute — the tick that finds a read still running counts the slot and stands down.
@@ -89,10 +103,15 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
89
103
  if (held !== undefined && (held.at === null || now - held.at < rangeCacheMs))
90
104
  return held.reading;
91
105
  const entry = { at: null, reading: undefined };
92
- // Read once per range per minute rather than per request: `stats()` walks the directory, and
93
- // a journal that stopped at its cap stays stopped for hours, so a minute-old answer to that
94
- // question is the same answer.
95
- entry.reading = readRange({ dir: store.dir, range, now, capped: store.stats().capped }).then((answer) => {
106
+ // Asked of the STORE, which owns both ends of its own journal: this route never learns where
107
+ // the days come from, so the invented week `serve --demo` carries arrives here as an ordinary
108
+ // range read and the demo gets no rendering path of its own (#156). Seeded past the store
109
+ // only by the suite, whose stuck read no real directory can produce (#136).
110
+ //
111
+ // Read once per range per minute rather than per request: the store walks its directory to
112
+ // answer, and a journal that stopped at its cap stays stopped for hours, so a minute-old
113
+ // answer to that question is the same answer.
114
+ entry.reading = readJournal(store, range, now).then((answer) => {
96
115
  entry.at = Date.now();
97
116
  return answer;
98
117
  }, (e) => {
@@ -106,6 +125,36 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
106
125
  ranges.set(range, entry);
107
126
  return entry.reading;
108
127
  };
128
+ /**
129
+ * The same read, with a deadline on the REQUEST rather than on the read.
130
+ *
131
+ * A read that does not come back is not a read that can be taken back: nothing cancels a
132
+ * blocked `open`, and the thread it holds is held until the kernel hands it over. So the entry
133
+ * stays in the cache, and every request that arrives while it is out gives up on its own clock
134
+ * instead of starting a second read of a directory that is already not answering. If it ever
135
+ * does land, it is served — to whoever is asking then. The price is written down rather than
136
+ * hidden: an entry that never settles is never dated, so that range answers 504 for the life
137
+ * of the process, and a restart is what reads it again.
138
+ *
139
+ * What this bounds is the WAIT. `rangeOf` measures the directory synchronously before the read
140
+ * begins (`readdirSync`, then a `statSync` a file), so a volume that has stopped answering
141
+ * stops the event loop this timer would have to fire on. A deadline cannot save a thread that
142
+ * is not running; only asynchronous work is bounded here, and that is the whole of the claim.
143
+ */
144
+ const answerRange = (store, range) => new Promise((resolve, reject) => {
145
+ const timer = setTimeout(() => reject(new JournalTimeout(`nothing came back from ${store.dir} in ${rangeDeadlineMs}ms`)), rangeDeadlineMs);
146
+ // The nominal path pays one timer and no latency: the answer clears it on the way past.
147
+ // The unref is the belt, and a narrow one worth naming rather than overselling: while the
148
+ // request is out its own socket holds the loop, so the only window this covers is a
149
+ // deadline still counting after the socket has gone. `bounded.ts` carries the same line
150
+ // for a case that IS reachable in a test, and has one; this one has no test of its own.
151
+ timer.unref();
152
+ // Attached, rather than raced with `Promise.race`: this is what marks the shared read's own
153
+ // failure handled when it arrives after the request that started it has already given up.
154
+ rangeOf(store, range)
155
+ .then(resolve, reject)
156
+ .finally(() => clearTimeout(timer));
157
+ });
109
158
  const sample = async () => {
110
159
  // First, and outside the try: the journal's lock says this process is alive, which is a
111
160
  // fact about the tick and not about the reading. A collector that has been throwing for
@@ -135,7 +184,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
135
184
  // reader trusted on top of that is a name, exactly: matched whole, never as a prefix, a
136
185
  // suffix or a pattern, so trusting one host can never be trusting a family of them.
137
186
  if (!isLoopbackHost(req.headers.host) && !isTrustedHost(req.headers.host, trusted)) {
138
- res.writeHead(403, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
187
+ res.writeHead(403, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
139
188
  res.end(refusal);
140
189
  return;
141
190
  }
@@ -146,7 +195,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
146
195
  // spawned; a client that sends no label (curl, a script) is left alone.
147
196
  const site = req.headers['sec-fetch-site'];
148
197
  if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') {
149
- res.writeHead(403, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
198
+ res.writeHead(403, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
150
199
  res.end('tarmac serves same-origin requests only\n');
151
200
  return;
152
201
  }
@@ -163,7 +212,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
163
212
  url.pathname !== '/live' &&
164
213
  url.pathname !== '/api/fleet' &&
165
214
  url.pathname !== '/api/history') {
166
- res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
215
+ res.writeHead(404, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
167
216
  res.end('not found\n');
168
217
  return;
169
218
  }
@@ -179,7 +228,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
179
228
  if (!isRange(asked)) {
180
229
  // The value is not quoted back, for the reason the refused Host is not: it is a string
181
230
  // the caller wrote, and the page swaps a refusal's text into `innerHTML` as its reason.
182
- res.writeHead(400, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
231
+ res.writeHead(400, { ...identity, 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
183
232
  res.end(`tarmac serves /api/history for 24h, ${HISTORY_RANGES.join(' and ')}; no range is the last 24h\n`);
184
233
  return;
185
234
  }
@@ -187,17 +236,22 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
187
236
  // would draw a flat line over a month the fleet was busy for.
188
237
  let body;
189
238
  try {
190
- body = store === null ? { enabled: false, range: asked } : { enabled: true, ...(await rangeOf(store, asked)) };
239
+ body = store === null ? { enabled: false, range: asked } : { enabled: true, ...(await answerRange(store, asked)) };
191
240
  }
192
241
  catch (e) {
193
242
  // Nothing in `readRange` is allowed to throw, and this is the seam that keeps a day
194
243
  // when something does from being a request that hangs instead of an answer.
195
- res.writeHead(500, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
244
+ //
245
+ // A read that has not come back is the other failure, and it is not the same news: 500
246
+ // is a read that came back with a failure, 504 is a read that did not come back at all
247
+ // — the first sends a reader to the file, the second to the directory. The page prints
248
+ // whichever sentence arrives as the reason the charts are empty.
249
+ res.writeHead(e instanceof JournalTimeout ? 504 : 500, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
196
250
  res.end(`tarmac could not read the fleet journal:\n${reason(e)}\n`);
197
251
  return;
198
252
  }
199
253
  res.writeHead(200, {
200
- ...IDENTITY,
254
+ ...identity,
201
255
  'content-type': 'application/json; charset=utf-8',
202
256
  'cache-control': 'no-store',
203
257
  });
@@ -207,7 +261,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
207
261
  }
208
262
  if (url.pathname === '/api/history') {
209
263
  res.writeHead(200, {
210
- ...IDENTITY,
264
+ ...identity,
211
265
  'content-type': 'application/json; charset=utf-8',
212
266
  'cache-control': 'no-store',
213
267
  });
@@ -239,18 +293,18 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
239
293
  body =
240
294
  url.pathname === '/live'
241
295
  ? renderLive(fleet)
242
- : renderPage(fleet, PAGES.get(url.pathname), { historyEnabled: store !== null });
296
+ : renderPage(fleet, PAGES.get(url.pathname), { historyEnabled: store !== null, demo });
243
297
  }
244
298
  }
245
299
  catch (e) {
246
300
  // Say why. A dashboard that goes blank when its source breaks teaches nothing.
247
- res.writeHead(500, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
301
+ res.writeHead(500, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
248
302
  res.end(`tarmac could not read the fleet:\n${reason(e)}\n`);
249
303
  return;
250
304
  }
251
305
  // A page whose entire claim is freshness must not be served from a cache: a restored tab
252
306
  // re-running the script over stale HTML would re-stamp it "updated just now".
253
- res.writeHead(200, { ...IDENTITY, 'content-type': type, 'cache-control': 'no-store' });
307
+ res.writeHead(200, { ...identity, 'content-type': type, 'cache-control': 'no-store' });
254
308
  res.end(body);
255
309
  });
256
310
  // The sampler lives exactly as long as the serving does. Started at construction it kept
@@ -266,6 +320,13 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
266
320
  server.on('listening', () => {
267
321
  if (sampler !== null)
268
322
  return;
323
+ // A demo does not sample. Its collector answers one frozen minute, so every tick would
324
+ // record that same minute into the ring and push a minute of the invented day off the far
325
+ // end: one slot a minute, until a serve left up overnight is showing 1440 copies of one
326
+ // reading. That is the flat chart this whole feature exists to replace. The record it was
327
+ // handed is the record it keeps, and the live view stays honestly dated either way.
328
+ if (demo)
329
+ return;
269
330
  // The journal's retention, applied before the first line of this run is written and once a
270
331
  // local day after that (the store keeps that half itself). Here rather than in the CLI so
271
332
  // that the store `serve` prunes with is, provably, the store `serve` writes with: a `serve`
package/dist/sessions.js CHANGED
@@ -36,6 +36,31 @@ const KNOWN_STATUS = new Map([
36
36
  const WAITING = 'waiting';
37
37
  /** Whether this reading is halted on a human. The one status the renderers treat as a state. */
38
38
  export const isWaiting = (s) => s.status === WAITING;
39
+ /**
40
+ * The kind a terminal calls itself, and the anchor the two rules below reason from. A
41
+ * background entry has since been seen beside them — `kind: 'background'`, no `pid`, its word
42
+ * under `state` rather than `status` — so the two are no longer a reading of that CLI's help.
43
+ * It is still the anchor and never the list: one observed alternative is not the vocabulary,
44
+ * and the question asked is only whether anything on this machine still calls itself
45
+ * `interactive`.
46
+ */
47
+ export const INTERACTIVE = 'interactive';
48
+ /**
49
+ * Whether this fleet still speaks the kind we know. If NOTHING calls itself `interactive`, the
50
+ * word moved rather than every terminal on the machine going background at once — and the
51
+ * readers say so by treating them all as what they almost certainly still are. Same tolerance
52
+ * `buildFleet` already applies to telemetry: a signal true of every row is a change in the
53
+ * source.
54
+ */
55
+ export const anchoredOnKind = (rows) => rows.some((r) => r.kind === INTERACTIVE);
56
+ /**
57
+ * A background entry, under the anchor above. An ABSENT kind is not evidence of one: the same
58
+ * rule the status follows, where unrecognised means unknown, never "the quiet one". The two
59
+ * mistakes are not the same size — an agent read as a terminal is a row expecting a frame that
60
+ * will not come, while a terminal read as an agent is someone's open session dropped out of
61
+ * every count that is about them.
62
+ */
63
+ export const isBackgroundAgent = (r, anchored) => anchored && r.kind !== null && r.kind !== INTERACTIVE;
39
64
  /** @param text raw stdout of `claude agents --json` */
40
65
  export function parseAgents(text) {
41
66
  let raw;
@@ -55,8 +80,14 @@ export function parseAgents(text) {
55
80
  health.noSessionId += 1;
56
81
  continue;
57
82
  }
58
- const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
59
- if (!sessionId)
83
+ // The empty string is not an id, and it is a `string`, so the type check alone let it
84
+ // through: counted as missing here, then carried as `''` to every reader and written to
85
+ // the journal as `sid: ''`. Each reader neutralises that value its own way (#137) —
86
+ // normalising at the source says it once, and stops counting the same nameless entry
87
+ // both here and as unfilable downstream. Absent reads `null` like every field here;
88
+ // sessionId alone also folds `''` into absent, an empty id being no id at all.
89
+ const sessionId = typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null;
90
+ if (sessionId === null)
60
91
  health.noSessionId += 1;
61
92
  // A background agent carries no `status` at all — its word is under `state`. `status`
62
93
  // still wins where both are present: it comes from the agent's own process, and `state`
package/dist/snapshots.js CHANGED
@@ -86,11 +86,13 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
86
86
  snapshots,
87
87
  dirError: code === 'ENOENT' ? null : `${code}: ${dir}`,
88
88
  unreadable: 0,
89
+ notFiles: 0,
89
90
  duplicates: 0,
90
91
  dirMissing: code === 'ENOENT',
91
92
  };
92
93
  }
93
94
  let unreadable = 0;
95
+ let notFiles = 0;
94
96
  let duplicates = 0;
95
97
  for (const name of entries) {
96
98
  if (!name.endsWith('.json') || name.startsWith('.'))
@@ -99,7 +101,27 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
99
101
  let payload;
100
102
  let mtimeMs;
101
103
  try {
102
- mtimeMs = fs.statSync(file).mtimeMs;
104
+ // The kind is asked BEFORE the open, and only a regular file is opened. What wears a
105
+ // snapshot's name is not this reader's to assume: the directory belongs to whoever owns
106
+ // the machine and a wrapper can be pointed at any of them, while `readFileSync` on a FIFO
107
+ // waits for someone to write to the other end — a wait with nothing to end it, on the
108
+ // loop under every surface tarmac has (#160).
109
+ //
110
+ // `lstat`, not `stat`, and not for the pipe: `stat` refuses a link to one exactly as this
111
+ // does. The one shape the two disagree about is a link to an ordinary file, and refusing
112
+ // that is the choice — a snapshot is what the wrapper wrote, and a link is a name someone
113
+ // else put there, aimed at something nobody told this reader about. The journal reader
114
+ // decides the same way for the same reason (#159); this is the hotter path.
115
+ //
116
+ // A check before an open is a race, and it stays one: nothing stops the name being
117
+ // replaced between the two. What that costs is bounded — one blocked read, on a directory
118
+ // somebody is racing — where reading the kind off the open would cost every read.
119
+ const stat = fs.lstatSync(file);
120
+ if (!stat.isFile()) {
121
+ notFiles += 1;
122
+ continue;
123
+ }
124
+ mtimeMs = stat.mtimeMs;
103
125
  payload = JSON.parse(fs.readFileSync(file, 'utf8'));
104
126
  }
105
127
  catch (e) {
@@ -108,15 +130,9 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
108
130
  // and a race with our own housekeeping rather than a payload we failed to parse.
109
131
  // Counting it made tarmac drive its own format-drift warning (up to 2675 phantom
110
132
  // 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.
133
+ // often enough to be inside that window). A dead link named like a snapshot used to land
134
+ // here too, `statSync` having followed it — a permanent state skipped as a passing race;
135
+ // the kind check above refuses it by name now, and counts it.
120
136
  //
121
137
  // ENOENT only. A file we were not ALLOWED to open still counts, and must.
122
138
  if (e.code !== 'ENOENT')
@@ -134,7 +150,7 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
134
150
  duplicates += 1;
135
151
  snapshots.set(t.sessionId, already ? preferred(already, snapshot) : snapshot);
136
152
  }
137
- return { snapshots, dirError: null, unreadable, duplicates, dirMissing: false };
153
+ return { snapshots, dirError: null, unreadable, notFiles, duplicates, dirMissing: false };
138
154
  }
139
155
  /**
140
156
  * Which of two snapshots claiming one session a reader is shown.
package/dist/wrapper.js CHANGED
@@ -157,15 +157,20 @@ esac
157
157
  # --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
158
158
  if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
159
159
  tmp="$TARMAC_DIR/${TEMP_PREFIX}$sid.$$.tmp"
160
- # \`2>/dev/null\` comes FIRST, and the order is the whole point: redirections are applied
161
- # left to right, so \`> "$tmp" 2>/dev/null\` opens the temp file while stderr is STILL the
162
- # user's terminal — the shell prints its own \`cannot create …: Permission denied\` there,
163
- # and the \`2>\` that was meant to swallow it only takes effect afterwards. On a snapshot
164
- # directory that has become read-only that is one line of noise per FRAME, on the terminal
165
- # of a script whose first rule is to be invisible. Exit code and display are untouched
166
- # (\`printf\` is a regular built-in, so a failed redirection only fails the command), which
167
- # is exactly why nothing but stderr itself catches this. RULE 1.
168
- if printf '%s\\n' "$payload" 2>/dev/null > "$tmp"; then
160
+ # The \`2>/dev/null\` is on the GROUP, and that placement is the whole point: a redirection
161
+ # that cannot be performed is reported by the shell itself, not by \`printf\`, so it has to
162
+ # be silenced by something already in force when the failing redirection is attempted. On
163
+ # the simple command it is not — \`> "$tmp" 2>/dev/null\` opens the temp file while stderr is
164
+ # STILL the user's terminal, and even \`2>/dev/null > "$tmp"\`, which reads as the fix and
165
+ # was one under every POSIX sh, leaves zsh printing \`permission denied\` on the terminal.
166
+ # The group is entered first, so its stderr is /dev/null before the command inside it is
167
+ # attempted, and that holds under every shell here. On a snapshot directory that has become
168
+ # read-only the difference is one line of noise per FRAME, on the terminal of a script whose
169
+ # first rule is to be invisible. Exit code and display are untouched either way (\`printf\` is
170
+ # a regular built-in, so a failed redirection only fails the command, and a group carries
171
+ # the status of what it ran), which is exactly why nothing but stderr itself catches this.
172
+ # RULE 1.
173
+ if { printf '%s\\n' "$payload" > "$tmp"; } 2>/dev/null; then
169
174
  mv -f "$tmp" "$TARMAC_DIR/$sid.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
170
175
  else
171
176
  rm -f "$tmp" 2>/dev/null
@@ -293,7 +298,19 @@ else
293
298
  case "$rest" in
294
299
  *'"'*)
295
300
  rest=\${rest#*'"'}
296
- printf '%s\\n' "\${rest%%'"'*}"
301
+ # Cut first, print second, and the two lines are not one on purpose. Spelled
302
+ # \`printf '%s\\n' "\${rest%%'"'*}"\` — a quoted pattern inside a quoted expansion —
303
+ # zsh reads the inner \`'\` as a literal character rather than as quoting, and the
304
+ # script ends on an unmatched \`"\`: a parse error, so nothing runs and the status
305
+ # line is blank. No POSIX sh does that, and no distribution ships zsh as
306
+ # \`/bin/sh\`, but linking it there is one command and this file's promise is that
307
+ # the wrapper runs wherever it is pointed. Dropping the outer quotes is the other
308
+ # way to make it parse, and it is wrong: unquoted, \`Fable 5\` reaches \`printf\` as
309
+ # two arguments and prints on two lines, and a \`*\` in a display name is expanded
310
+ # against the working directory. An assignment splits nothing and globs nothing,
311
+ # so the cut is safe unquoted and the value is quoted where it is used.
312
+ display=\${rest%%'"'*}
313
+ printf '%s\\n' "$display"
297
314
  ;;
298
315
  esac
299
316
  ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",