@adrrr/tarmac 0.8.0 → 0.9.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/render.js CHANGED
@@ -192,6 +192,10 @@ export function renderTable({ rows, health }) {
192
192
  warns.push(`! ${health.snapshotsUnreadable} snapshot file(s) present but unreadable — schema may have moved, check for a newer tarmac`);
193
193
  if ((health.snapshotsDuplicates ?? 0) > 0)
194
194
  warns.push(`! ${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest was kept`);
195
+ // Not folded into the line above: a name that is not a file is a directory somebody put
196
+ // something in, and sending them to look for a newer tarmac would be advice for the schema.
197
+ if ((health.snapshotsNotFiles ?? 0) > 0)
198
+ warns.push(`! ${health.snapshotsNotFiles} name(s) in the snapshot directory are not regular files — stepped over unread, never opened`);
195
199
  // Covers both "not allowed to look" and "there is nothing there to look at", so the words
196
200
  // have to fit an errno as well as a path that points nowhere.
197
201
  if (health.snapshotsError)
@@ -365,6 +369,9 @@ export function renderLive(fleet) {
365
369
  if ((health.snapshotsDuplicates ?? 0) > 0) {
366
370
  warnings.push(`${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest reading was kept and the other ignored. Two wrappers may be writing into the same directory.`);
367
371
  }
372
+ if ((health.snapshotsNotFiles ?? 0) > 0) {
373
+ warnings.push(`${health.snapshotsNotFiles} name(s) in the snapshot directory are not regular files — a directory, a link or a named pipe wearing a snapshot's name is stepped over rather than opened, because reading one can wait for ever. Whatever else is there was read as usual.`);
374
+ }
368
375
  if (health.snapshotsError) {
369
376
  // A permission error is ours to report, not the user's to be blamed for.
370
377
  warnings.push(`The snapshot directory could not be used — ${health.snapshotsError}. Context readings are unavailable, and this is not an install problem.`);
@@ -576,7 +583,7 @@ function ago(ms) {
576
583
  const m = Math.round(s / 60);
577
584
  return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
578
585
  }
579
- export function renderPage(fleet, view = 'table', { historyEnabled = false } = {}) {
586
+ export function renderPage(fleet, view = 'table', { historyEnabled = false, demo = false } = {}) {
580
587
  // The header's copy. `renderLive` below renders its own, out of this same fleet and through
581
588
  // this same function — two calls of one pure renderer over one reading, which is what keeps
582
589
  // the pair the reader sees and the pair the script will copy up from being two accounts.
@@ -598,7 +605,15 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false } = {
598
605
  font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
599
606
  header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
600
607
  h1 { font-size:1.1rem; margin:0; letter-spacing:.02em; }
601
- .meta { color:var(--dim); font-size:.85rem; }
608
+ .meta { color:var(--dim); font-size:.85rem; }${demo
609
+ ? `
610
+ /* The demo marker. It borrows the warning's hues rather than the dim chrome the tabs use:
611
+ what it says is not chrome, and a badge a reader's eye files with the furniture is a badge
612
+ that is not in the screenshot as far as anybody looking at the screenshot is concerned.
613
+ Shipped only on a demo, so a plain serve is byte-for-byte the page it always was. */
614
+ .demo-tag { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:99px;
615
+ padding:.05rem .55rem; font-size:.75rem; font-weight:600; letter-spacing:.02em; white-space:nowrap; }`
616
+ : ''}
602
617
  /* Honest, and out of the way of the fleet: three of these stacked at full padding pushed
603
618
  the table below the fold on a laptop, which is its own kind of hidden. */
604
619
  .warn { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:6px;
@@ -1077,7 +1092,13 @@ ${HISTORY_PHONE_CSS} }
1077
1092
  </style>
1078
1093
  </head><body data-view="${view}">
1079
1094
  <header>
1080
- <h1>tarmac</h1>
1095
+ <h1>tarmac</h1>${demo
1096
+ ? `
1097
+ <!-- Beside the title, in the shell, and never quiet. The one thing a screenshot of this page
1098
+ must not be able to do is pass for a real fleet, and the reader who can be misled is not
1099
+ the one running the serve — it is whoever is sent the picture afterwards. -->
1100
+ <span class="demo-tag" role="status">demo data &mdash; an invented fleet</span>`
1101
+ : ''}
1081
1102
  <!-- Links, not buttons: the view survives a reload, a bookmark and a browser with
1082
1103
  JavaScript off — the state of a page whose own noscript banner promises it is still
1083
1104
  readable. Both views are in the fragment below either way, so switching costs the
@@ -1137,7 +1158,7 @@ ${HISTORY_PHONE_CSS} }
1137
1158
  <input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
1138
1159
  <div class="covers" id="covers"></div>
1139
1160
  </div>
1140
- ${view === 'history' ? renderHistoryView({ historyEnabled }) : ''}
1161
+ ${view === 'history' ? renderHistoryView({ historyEnabled, demo }) : ''}
1141
1162
  <script>${pageScript(view)}</script>${view === 'history' ? `\n<script>${historyScript()}</script>` : ''}
1142
1163
  </body></html>
1143
1164
  `;
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
@@ -55,8 +55,14 @@ export function parseAgents(text) {
55
55
  health.noSessionId += 1;
56
56
  continue;
57
57
  }
58
- const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
59
- if (!sessionId)
58
+ // The empty string is not an id, and it is a `string`, so the type check alone let it
59
+ // through: counted as missing here, then carried as `''` to every reader and written to
60
+ // the journal as `sid: ''`. Each reader neutralises that value its own way (#137) —
61
+ // normalising at the source says it once, and stops counting the same nameless entry
62
+ // both here and as unfilable downstream. Absent reads `null` like every field here;
63
+ // sessionId alone also folds `''` into absent, an empty id being no id at all.
64
+ const sessionId = typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null;
65
+ if (sessionId === null)
60
66
  health.noSessionId += 1;
61
67
  // A background agent carries no `status` at all — its word is under `state`. `status`
62
68
  // 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",
@@ -13,7 +13,7 @@
13
13
  "context-window"
14
14
  ],
15
15
  "license": "MIT",
16
- "author": "Adrien Leboeuf",
16
+ "author": "adrrr",
17
17
  "repository": {
18
18
  "type": "git",
19
19
  "url": "git+https://github.com/adrrr/tarmac.git"