@adrrr/tarmac 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/history.js CHANGED
@@ -42,6 +42,7 @@ export function createHistory({ since, cadence }) {
42
42
  record(fleet) {
43
43
  const sample = sampleOf(fleet);
44
44
  push({ t: sample.t, sample });
45
+ return sample;
45
46
  },
46
47
  miss(t) {
47
48
  push({ t, sample: null });
package/dist/reap.js CHANGED
@@ -14,6 +14,12 @@
14
14
  // that same constant: writer and deleter cannot drift apart.
15
15
  // • only what is finished. A frame takes milliseconds; anything recent may be a write
16
16
  // in flight, and deleting it would be the reaper causing the corruption it prevents.
17
+ //
18
+ // `history/` is not ours. The fleet journal a reader may opt into lives in a SIBLING directory
19
+ // of the snapshots, and `history-store.ts` is its only writer and its only sweeper. Nothing
20
+ // here reaches outside the directory it is handed, and that is why the journal is beside the
21
+ // payloads rather than among them: three sweepers deciding by name over one directory is how a
22
+ // file comes to be deleted by whoever matched it last.
17
23
  import fs from 'node:fs';
18
24
  import path from 'node:path';
19
25
  import { SID_GLOB, TEMP_PREFIX } from './wrapper.js';
package/dist/render.js CHANGED
@@ -7,7 +7,9 @@
7
7
  // stays invisible for days. The two surfaces say the same things in their own words — so
8
8
  // they are written side by side, and the suite can reach both.
9
9
  import { formatDuration } from './config.js';
10
+ import { HISTORY_MAX_BYTES } from './history-store.js';
10
11
  import { buildMap, INTERACTIVE, stateOf } from './map.js';
12
+ import { HISTORY_CSS, HISTORY_PHONE_CSS, HISTORY_PALETTE, HISTORY_TOUCH_CSS, historyScript, renderHistoryView, } from './history-view.js';
11
13
  import { schemaNotice } from './schema.js';
12
14
  import { LIMIT_WINDOWS, RESET_HORIZON_MS, readLimits } from './limits.js';
13
15
  import { accountLimits, busyOnStaleFleet } from './fleet.js';
@@ -131,11 +133,24 @@ export const servingLine = ({ port, movedFrom }) => `tarmac serving http://127.0
131
133
  * one nobody can go and correct — and pointing at an empty snapshots directory looks exactly
132
134
  * like a fleet with no statusline chained.
133
135
  */
134
- export function renderSettings(config, configFile) {
136
+ export function renderSettings(config, configFile, historyDir) {
137
+ const days = config.historyDays.value;
135
138
  const rows = [
136
139
  ['freshness', formatDuration(config.staleAfterMs.value), config.staleAfterMs.source],
137
140
  ['port', String(config.port.value), config.port.source],
138
141
  ['snapshots', config.snapshotsDir.value, config.snapshotsDir.source],
142
+ // Unlike the trusted hosts below, this line is printed either way. Off is the default and
143
+ // the product, and a reader who came looking for their week of history has to be able to
144
+ // read, in the same block as everything else, that there is none and which key starts it.
145
+ // On, it is the only setting here that writes to their disk, so it says all of it at once:
146
+ // how long it keeps, the ceiling they did not set, and the directory to go and look in.
147
+ [
148
+ 'history',
149
+ days === null
150
+ ? 'off (set history.days to keep more than 24 h)'
151
+ : `${days} days (about 2 MB a day at 8 sessions, hard cap ${HISTORY_MAX_BYTES / (1024 * 1024)} MB) ${historyDir}`,
152
+ config.historyDays.source,
153
+ ],
139
154
  ];
140
155
  // Only when there are any. An empty list is what every other run has, chosen by nobody —
141
156
  // a `(default)` line saying "none" on every serve is noise, and this line has to read as
@@ -561,7 +576,7 @@ function ago(ms) {
561
576
  const m = Math.round(s / 60);
562
577
  return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
563
578
  }
564
- export function renderPage(fleet, view = 'table') {
579
+ export function renderPage(fleet, view = 'table', { historyEnabled = false } = {}) {
565
580
  // The header's copy. `renderLive` below renders its own, out of this same fleet and through
566
581
  // this same function — two calls of one pure renderer over one reading, which is what keeps
567
582
  // the pair the reader sees and the pair the script will copy up from being two accounts.
@@ -576,6 +591,9 @@ export function renderPage(fleet, view = 'table') {
576
591
  a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
577
592
  :root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8; }
578
593
  @media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; --wait:#93c5fd; } }
594
+ /* Eight categorical hues for the history view, kept apart from the four above: those four
595
+ say what a session is DOING, and a chart that borrowed one would be colouring a project
596
+ with the word for busy. */${HISTORY_PALETTE}
579
597
  body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
580
598
  font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
581
599
  header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
@@ -652,13 +670,13 @@ export function renderPage(fleet, view = 'table') {
652
670
  Map, is 50), so a horizontal inset buys nothing — and at .3rem against a .15rem gap between
653
671
  the tabs it made their two overlays overlap by 7px, where a tap meant for Table landed on
654
672
  Map because Map's pseudo paints later. */
655
- nav a, .replay button, .replaying-note button { position:relative; }
673
+ nav a, .replay button, .replaying-note button, .hist-range button, .to-now, .key { position:relative; }
656
674
  @media (pointer: coarse) {
657
675
  nav a::after, .replay button::after { content:''; position:absolute; inset:-.7rem 0; }
658
- .replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }
659
- }
676
+ .replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }${HISTORY_TOUCH_CSS} }
660
677
  body[data-view="table"] .view-map { display:none; }
661
678
  body[data-view="map"] .view-table { display:none; }
679
+ ${HISTORY_CSS}
662
680
 
663
681
  /* ── the account's two windows ───────────────────────────────────────────────────────
664
682
  In the header, because a rate limit is the account's and not a node's. Slim on purpose:
@@ -988,7 +1006,19 @@ export function renderPage(fleet, view = 'table') {
988
1006
  word. min-width:0 for the same reason the berths carry it: a flex item's automatic
989
1007
  minimum is its min-content width, which without this is the whole unbroken string. */
990
1008
  td[data-label="Project"] .v { order:1; font-weight:600; min-width:0; overflow-wrap:anywhere; }
991
- td[data-label="Session"] .v { order:2; flex:1 1 0; min-width:0; color:var(--dim);
1009
+ /* The basis is auto and not 0, which is what decides whether the name breaks INSIDE
1010
+ itself. A basis of 0 puts it on the first line however little is left of one: at 390px
1011
+ beside a 40-character project that is 44.6px of column, a session id cut across two
1012
+ lines, the pill dropped alone underneath and a 63px strip standing at 105.7 — on
1013
+ exactly the fleets whose checkout names are long. Its own width is the floor it wraps
1014
+ at instead: beside the project where it fits, on the next line whole where it does not.
1015
+ That floor is a trade, not a free fix: a name longer than one full line keeps a line of
1016
+ its own at EVERY width, where a basis of 0 let it compress back beside the project as
1017
+ the screen widened. The accepted side of #108: never cut a name mid-word, at the price
1018
+ of a taller strip for prompt-named agents.
1019
+ min-width:0 is what still lets it shrink once it is the widest thing on a line of its
1020
+ own, which is the wrap the rule above is written for. */
1021
+ td[data-label="Session"] .v { order:2; flex:1 1 auto; min-width:0; color:var(--dim);
992
1022
  white-space:normal; overflow-wrap:anywhere; }
993
1023
  /* The third value that can arrive as one unbroken token, and the one the two rules above
994
1024
  missed: a waiting reason is FREE TEXT, so "permission prompt: /Users/…/foo.ts" is a path
@@ -1043,7 +1073,7 @@ export function renderPage(fleet, view = 'table') {
1043
1073
  td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
1044
1074
  td[data-label="Cost"] .v:has(.dim)::before { content:'· cost '; }
1045
1075
  .bar { display:none; }
1046
- }
1076
+ ${HISTORY_PHONE_CSS} }
1047
1077
  </style>
1048
1078
  </head><body data-view="${view}">
1049
1079
  <header>
@@ -1055,6 +1085,7 @@ export function renderPage(fleet, view = 'table') {
1055
1085
  <nav>
1056
1086
  <a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
1057
1087
  <a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
1088
+ <a href="/history"${view === 'history' ? ' aria-current="page"' : ''}>History</a>
1058
1089
  </nav>
1059
1090
  <!-- The account's two windows, page-level because that is what they are: a limit belongs to
1060
1091
  the account every session below is spending from, not to any one of them. Their VALUES
@@ -1106,7 +1137,8 @@ export function renderPage(fleet, view = 'table') {
1106
1137
  <input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
1107
1138
  <div class="covers" id="covers"></div>
1108
1139
  </div>
1109
- <script>${pageScript(view)}</script>
1140
+ ${view === 'history' ? renderHistoryView({ historyEnabled }) : ''}
1141
+ <script>${pageScript(view)}</script>${view === 'history' ? `\n<script>${historyScript()}</script>` : ''}
1110
1142
  </body></html>
1111
1143
  `;
1112
1144
  }
@@ -1321,12 +1353,20 @@ function pageScript(view) {
1321
1353
  }
1322
1354
 
1323
1355
  function two(n) { return (n < 10 ? '0' : '') + n; }
1324
- function hhmm(t) { var d = new Date(t); return two(d.getHours()) + ':' + two(d.getMinutes()); }
1356
+ // One page, one clock, and it is the one the summary line already dates the fleet on: an ISO
1357
+ // instant in UTC. Spelled on the machine's own clock, these minutes and that stamp are the
1358
+ // same fleet apparently living in two time zones, the first time anything shows both — an
1359
+ // export, a log line, a screenshot with the header in it. Every minute below carries UTC
1360
+ // where a reader meets it, because an unlabelled one reads as the reader's own clock and is
1361
+ // the same lie facing the other way.
1362
+ function hhmm(t) { var d = new Date(t); return two(d.getUTCHours()) + ':' + two(d.getUTCMinutes()); }
1325
1363
 
1326
1364
  // A day-long ring can straddle one midnight, and "09:14 – 08:59" reads as a span running
1327
- // backwards until the older edge says which day it is.
1365
+ // backwards until the older edge says which day it is. Which midnight is UTC's, like the
1366
+ // minutes it separates: counted otherwise, "yesterday" turns up between two minutes of one
1367
+ // UTC day and goes missing across the midnight it exists for.
1328
1368
  function edge(t, ref) {
1329
- return hhmm(t) + (new Date(t).getDate() === new Date(ref).getDate() ? '' : ' yesterday');
1369
+ return hhmm(t) + (new Date(t).getUTCDate() === new Date(ref).getUTCDate() ? '' : ' yesterday');
1330
1370
  }
1331
1371
 
1332
1372
  // What the range covers, in the record's own terms. Never "a day": that is the size of the
@@ -1338,13 +1378,13 @@ function pageScript(view) {
1338
1378
  // this was the one branch that threw that away: ten hours of a collector that could not
1339
1379
  // run read exactly like a serve thirty seconds old.
1340
1380
  return record.missed
1341
- ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' and '
1381
+ ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' UTC and '
1342
1382
  + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
1343
- : 'Nothing recorded yet — this serve started at ' + hhmm(record.since)
1383
+ : 'Nothing recorded yet — this serve started at ' + hhmm(record.since) + ' UTC'
1344
1384
  + ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
1345
1385
  }
1346
1386
  var last = record.samples[n - 1].t;
1347
- return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last)
1387
+ return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last) + ' UTC'
1348
1388
  // Not "when the page loaded": the record is asked for again when a tab that has been
1349
1389
  // away comes back, so the sentence names the last time this page asked rather than a
1350
1390
  // moment it may be hours past.
@@ -1579,8 +1619,9 @@ function pageScript(view) {
1579
1619
  scrub.value = String(i);
1580
1620
  // The handle's own value is an index, so a reader who cannot see the banner would be read
1581
1621
  // "3" while the fleet on screen is three hours old. The minute travels with the handle.
1582
- scrub.setAttribute('aria-valuetext', hhmm(s.t));
1583
- atEl.textContent = hhmm(s.t);
1622
+ var minute = hhmm(s.t) + ' UTC';
1623
+ scrub.setAttribute('aria-valuetext', minute);
1624
+ atEl.textContent = minute;
1584
1625
  rmeta.textContent = metaOf(s);
1585
1626
  rmap.innerHTML = nodesOf(s);
1586
1627
  // The account of that minute, in the place the live pair occupies — which the body class
package/dist/server.js CHANGED
@@ -6,6 +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
10
  /**
10
11
  * On every answer, including the refusals and the 500s. The page swaps what this port returns
11
12
  * into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
@@ -19,8 +20,9 @@ const IDENTITY = { 'x-tarmac': '1' };
19
20
  const PAGES = new Map([
20
21
  ['/', 'table'],
21
22
  ['/map', 'map'],
23
+ ['/history', 'history'],
22
24
  ]);
23
- export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [] }) {
25
+ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [], store = null, rangeCacheMs = 60_000, report = (line) => console.error(line), }) {
24
26
  // Normalised HERE rather than trusted to arrive that way. This is the last thing between a
25
27
  // foreign origin and the fleet, so it owns both sides of its own comparison — the config
26
28
  // parser cuts a name the same way, and neither leans on the other having done it.
@@ -46,14 +48,76 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
46
48
  // than a slot would otherwise be answered with a queue of processes instead of one missed
47
49
  // minute — the tick that finds a read still running counts the slot and stands down.
48
50
  let reading = false;
51
+ // Said on the CHANGE, never on the tick. A read-only journal directory is a fact that holds
52
+ // for hours, and a line a minute about it is a log a reader learns to scroll past, which is
53
+ // the same as never having printed it. A failure that comes back after a run of good writes
54
+ // is a new fact and says so again, which is what the two flags below are for.
55
+ let misses = 0;
56
+ let failing = false;
57
+ let saidStopped = null;
58
+ const journal = (taken) => {
59
+ if (store === null)
60
+ return;
61
+ // Structurally best effort, not best effort by argument. The store swallows its own
62
+ // filesystem trouble, so nothing in here can throw today; but this is called from inside
63
+ // the sampler's try, where a throw would be caught as A FLEET READING THAT FAILED and
64
+ // counted as a missed minute in `/api/history` on top of the sample already pushed. The
65
+ // journal is not allowed to make the record of the fleet wrong, whatever it does to itself.
66
+ try {
67
+ store.append(taken);
68
+ const stats = store.stats();
69
+ if (stats.stopped !== saidStopped) {
70
+ saidStopped = stats.stopped;
71
+ if (stats.stopped !== null)
72
+ report(`tarmac: the fleet journal has stopped, ${stats.stopped}`);
73
+ }
74
+ const missed = stats.misses > misses;
75
+ misses = stats.misses;
76
+ if (missed && !failing) {
77
+ report(`tarmac: could not write to the fleet journal in ${store.dir}; that reading is lost`);
78
+ }
79
+ failing = missed;
80
+ }
81
+ catch {
82
+ // Including a `report` a caller wrote that throws: this one is not ours to be right.
83
+ }
84
+ };
85
+ const ranges = new Map();
86
+ const rangeOf = (store, range) => {
87
+ const now = Date.now();
88
+ const held = ranges.get(range);
89
+ if (held !== undefined && (held.at === null || now - held.at < rangeCacheMs))
90
+ return held.reading;
91
+ 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) => {
96
+ entry.at = Date.now();
97
+ return answer;
98
+ }, (e) => {
99
+ // A read that failed leaves nothing behind to be served for the rest of the minute. Only
100
+ // its own entry, though: a slow failure that cleared the map would drop the good answer a
101
+ // later request had already put there.
102
+ if (ranges.get(range) === entry)
103
+ ranges.delete(range);
104
+ throw e;
105
+ });
106
+ ranges.set(range, entry);
107
+ return entry.reading;
108
+ };
49
109
  const sample = async () => {
110
+ // First, and outside the try: the journal's lock says this process is alive, which is a
111
+ // fact about the tick and not about the reading. A collector that has been throwing for
112
+ // five minutes would otherwise leave the lock looking abandoned to the next serve.
113
+ store?.heartbeat();
50
114
  if (reading) {
51
115
  history.miss(Date.now());
52
116
  return;
53
117
  }
54
118
  reading = true;
55
119
  try {
56
- history.record(await collect());
120
+ journal(history.record(await collect()));
57
121
  }
58
122
  catch {
59
123
  // A collector that throws is the normal weather here: `claude` missing, a laptop that
@@ -106,6 +170,41 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
106
170
  // Served out of the ring, above the collect below and never through it: this route is
107
171
  // what the serve has ALREADY read, and one that collected would let a scrubber spawn
108
172
  // `claude agents --json` on every drag of its handle.
173
+ // The same route, one question further back: `range` sends it to the journal on disk for the
174
+ // week or the month the ring cannot hold. No range is the ring, and `24h` is the name of
175
+ // that, so a page may say which one it wants without asking for a different route.
176
+ if (url.pathname === '/api/history' && url.searchParams.has('range')) {
177
+ const asked = url.searchParams.get('range');
178
+ if (asked !== '24h') {
179
+ if (!isRange(asked)) {
180
+ // The value is not quoted back, for the reason the refused Host is not: it is a string
181
+ // 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' });
183
+ res.end(`tarmac serves /api/history for 24h, ${HISTORY_RANGES.join(' and ')}; no range is the last 24h\n`);
184
+ return;
185
+ }
186
+ // Off is not the same answer as an empty week: a page that could not tell them apart
187
+ // would draw a flat line over a month the fleet was busy for.
188
+ let body;
189
+ try {
190
+ body = store === null ? { enabled: false, range: asked } : { enabled: true, ...(await rangeOf(store, asked)) };
191
+ }
192
+ catch (e) {
193
+ // Nothing in `readRange` is allowed to throw, and this is the seam that keeps a day
194
+ // 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' });
196
+ res.end(`tarmac could not read the fleet journal:\n${reason(e)}\n`);
197
+ return;
198
+ }
199
+ res.writeHead(200, {
200
+ ...IDENTITY,
201
+ 'content-type': 'application/json; charset=utf-8',
202
+ 'cache-control': 'no-store',
203
+ });
204
+ res.end(JSON.stringify(body));
205
+ return;
206
+ }
207
+ }
109
208
  if (url.pathname === '/api/history') {
110
209
  res.writeHead(200, {
111
210
  ...IDENTITY,
@@ -134,7 +233,13 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
134
233
  }
135
234
  else {
136
235
  type = 'text/html; charset=utf-8';
137
- body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet, PAGES.get(url.pathname));
236
+ // Whether there is a journal is the config's answer and the server is the one holding
237
+ // it, so the view ships knowing — rather than drawing three live ranges and taking two
238
+ // of them back once a fetch has been out and come home refused.
239
+ body =
240
+ url.pathname === '/live'
241
+ ? renderLive(fleet)
242
+ : renderPage(fleet, PAGES.get(url.pathname), { historyEnabled: store !== null });
138
243
  }
139
244
  }
140
245
  catch (e) {
@@ -161,6 +266,17 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
161
266
  server.on('listening', () => {
162
267
  if (sampler !== null)
163
268
  return;
269
+ // The journal's retention, applied before the first line of this run is written and once a
270
+ // local day after that (the store keeps that half itself). Here rather than in the CLI so
271
+ // that the store `serve` prunes with is, provably, the store `serve` writes with: a `serve`
272
+ // that built one and forgot to hand it over would otherwise sweep and journal nothing.
273
+ if (store !== null) {
274
+ const { removed, failed } = store.prune();
275
+ if (removed > 0)
276
+ report(`tarmac: removed ${removed} journal file(s) older than ${store.days} days from ${store.dir}`);
277
+ if (failed > 0)
278
+ report(`tarmac: could not remove ${failed} journal file(s) under ${store.dir}`);
279
+ }
164
280
  sampler = setInterval(() => void sample(), sampleEveryMs);
165
281
  sampler.unref();
166
282
  });
@@ -241,6 +357,8 @@ function attempt(server, port, host) {
241
357
  server.listen(port, host);
242
358
  });
243
359
  }
360
+ /** One of the ranges the journal reader knows, matched whole. */
361
+ const isRange = (asked) => HISTORY_RANGES.includes(asked);
244
362
  function isLoopbackHost(host) {
245
363
  if (!host)
246
364
  return false;
package/dist/wrapper.js CHANGED
@@ -173,6 +173,11 @@ if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
173
173
  fi
174
174
 
175
175
  # --- prune the snapshots of sessions that stopped rendering (amortized) ---
176
+ # \`history/\` is not ours either: the journal a reader may opt into is a SIBLING directory of
177
+ # this one, swept by \`history-store.ts\` alone. The find below is bounded to \`\$TARMAC_DIR\` and
178
+ # matches the session-id shape, so it cannot reach it, and that is the point of the two
179
+ # directories being separate.
180
+ #
176
181
  # Nothing else would ever remove them: \`reap.ts\` collects this script's own temp litter and
177
182
  # refuses to touch a \`<sid>.json\`. A fleet that recycles its sessions nightly leaves one dead
178
183
  # file behind per session per night, forever. A LIVE session restamps its own snapshot on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",