@adrrr/tarmac 0.3.0 → 0.4.1

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
@@ -29,8 +29,8 @@
29
29
  * directory, so the two cannot drift apart in the repo.
30
30
  */
31
31
  export const CHECKED_VERSIONS = {
32
- statusline: ['2.1.220', '2.1.226'],
33
- agents: ['2.1.226'],
32
+ statusline: ['2.1.220', '2.1.226', '2.1.232'],
33
+ agents: ['2.1.226', '2.1.232'],
34
34
  };
35
35
  const SURFACE_LABEL = {
36
36
  statusline: 'statusline payload',
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import http from 'node:http';
6
6
  import { reason, renderLive, renderPage } from './render.js';
7
7
  import { SOURCE_PHRASE } from './config.js';
8
+ import { createHistory, HISTORY_CADENCE_MS } from './history.js';
8
9
  /**
9
10
  * On every answer, including the refusals and the 500s. The page swaps what this port returns
10
11
  * into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
@@ -19,8 +20,36 @@ const PAGES = new Map([
19
20
  ['/', 'table'],
20
21
  ['/map', 'map'],
21
22
  ]);
22
- export function createFleetServer({ collect }) {
23
- return http.createServer(async (req, res) => {
23
+ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS }) {
24
+ // What this serve has already read, kept for a day and never written down. `since` is the
25
+ // moment this server was made, not the first sample that landed: the span it covers is how
26
+ // long the process has been up, and an hour of it with nothing in it is a fact worth
27
+ // showing rather than an empty record pretending to be a young one.
28
+ const history = createHistory({ since: Date.now(), cadence: sampleEveryMs });
29
+ // One at a time. `claude agents --json` has a 15s deadline of its own, and a fleet slower
30
+ // than a slot would otherwise be answered with a queue of processes instead of one missed
31
+ // minute — the tick that finds a read still running counts the slot and stands down.
32
+ let reading = false;
33
+ const sample = async () => {
34
+ if (reading) {
35
+ history.miss(Date.now());
36
+ return;
37
+ }
38
+ reading = true;
39
+ try {
40
+ history.record(await collect());
41
+ }
42
+ catch {
43
+ // A collector that throws is the normal weather here: `claude` missing, a laptop that
44
+ // was asleep. It costs a slot and nothing else — a throw out of this timer would be an
45
+ // unhandled rejection, and `serve` runs unattended for hours.
46
+ history.miss(Date.now());
47
+ }
48
+ finally {
49
+ reading = false;
50
+ }
51
+ };
52
+ const server = http.createServer(async (req, res) => {
24
53
  // Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
25
54
  // from reading /api/fleet — which carries cwd paths, session ids and costs.
26
55
  if (!isLoopbackHost(req.headers.host)) {
@@ -48,11 +77,30 @@ export function createFleetServer({ collect }) {
48
77
  // the tabs are plain links, so the view has to be somewhere a reload and a bookmark can
49
78
  // both find it. There is no second fragment — one `/live` carries both views, which is
50
79
  // what keeps them from ever showing readings of different ages.
51
- if (!PAGES.has(url.pathname) && url.pathname !== '/live' && url.pathname !== '/api/fleet') {
80
+ if (!PAGES.has(url.pathname) &&
81
+ url.pathname !== '/live' &&
82
+ url.pathname !== '/api/fleet' &&
83
+ url.pathname !== '/api/history') {
52
84
  res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
53
85
  res.end('not found\n');
54
86
  return;
55
87
  }
88
+ // Served out of the ring, above the collect below and never through it: this route is
89
+ // what the serve has ALREADY read, and one that collected would let a scrubber spawn
90
+ // `claude agents --json` on every drag of its handle.
91
+ if (url.pathname === '/api/history') {
92
+ res.writeHead(200, {
93
+ ...IDENTITY,
94
+ 'content-type': 'application/json; charset=utf-8',
95
+ 'cache-control': 'no-store',
96
+ });
97
+ // Not indented, alone among the JSON answers here. `/api/fleet` pretty-prints ONE
98
+ // reading, which a person reads in a terminal; this is up to 1440 of them, where the
99
+ // indentation is 40% of a body no human will ever open — megabytes of whitespace on
100
+ // every poll of the replay.
101
+ res.end(JSON.stringify(history.read()));
102
+ return;
103
+ }
56
104
  // Read AND render inside the guard, and send nothing until there is something to send.
57
105
  // Writing the 200 first and rendering after made the collector's failure a 500 and the
58
106
  // renderer's failure a dead daemon: the headers were already on the wire, the throw
@@ -82,6 +130,29 @@ export function createFleetServer({ collect }) {
82
130
  res.writeHead(200, { ...IDENTITY, 'content-type': type, 'cache-control': 'no-store' });
83
131
  res.end(body);
84
132
  });
133
+ // The sampler lives exactly as long as the serving does. Started at construction it kept
134
+ // reading the fleet for a server whose `listen` had refused — a port named on the command
135
+ // line and taken — into a ring no request could ever reach; and never cleared, it did the
136
+ // same for every server a suite had closed behind it.
137
+ //
138
+ // `close` is the only shutdown this module has. `tarmac serve` itself has no graceful one:
139
+ // Ctrl-C ends the process, which takes the timer with it. Unref'ing is the belt to that
140
+ // braces — it keeps the sampler from being a reason `node --test`, or anything else that
141
+ // embeds this server, stays alive — and it is deliberately not the thing relied on.
142
+ let sampler = null;
143
+ server.on('listening', () => {
144
+ if (sampler !== null)
145
+ return;
146
+ sampler = setInterval(() => void sample(), sampleEveryMs);
147
+ sampler.unref();
148
+ });
149
+ server.on('close', () => {
150
+ if (sampler === null)
151
+ return;
152
+ clearInterval(sampler);
153
+ sampler = null;
154
+ });
155
+ return server;
85
156
  }
86
157
  /**
87
158
  * How far past a port NOBODY CHOSE `serve` may walk. A corridor, not a search: wide enough
package/dist/sessions.js CHANGED
@@ -27,6 +27,15 @@ const KNOWN_STATUS = new Map([
27
27
  ['working', true],
28
28
  ['done', false],
29
29
  ]);
30
+ /**
31
+ * The word for a session halted until a human answers, and the only one with a field saying
32
+ * which answer. It stays out of the map above — the boolean it would have to fill still has
33
+ * no honest value — but it is no longer counted as a word we failed to recognise: it has a
34
+ * state of its own on both surfaces, and a reason to print beside it.
35
+ */
36
+ const WAITING = 'waiting';
37
+ /** Whether this reading is halted on a human. The one status the renderers treat as a state. */
38
+ export const isWaiting = (s) => s.status === WAITING;
30
39
  /** @param text raw stdout of `claude agents --json` */
31
40
  export function parseAgents(text) {
32
41
  let raw;
@@ -54,7 +63,11 @@ export function parseAgents(text) {
54
63
  // is what the dispatcher believes about an agent whose process may not be on this machine.
55
64
  const status = typeof entry.status === 'string' ? entry.status : typeof entry.state === 'string' ? entry.state : null;
56
65
  const busy = KNOWN_STATUS.has(status) ? KNOWN_STATUS.get(status) : null;
57
- if (busy === null)
66
+ // Two different questions, and only the second one is a blind spot: `busy` is null here
67
+ // for a word we cannot answer the boolean with, and `waiting` is one of those — while
68
+ // being a word this tool knows by name. Counting it would put "reports a status tarmac
69
+ // does not know" on the banner over a session tarmac is drawing, captioned, as waiting.
70
+ if (busy === null && status !== WAITING)
58
71
  health.unknownStatus += 1;
59
72
  sessions.push({
60
73
  sessionId,
@@ -64,6 +77,7 @@ export function parseAgents(text) {
64
77
  kind: typeof entry.kind === 'string' ? entry.kind : null,
65
78
  startedAt: typeof entry.startedAt === 'number' ? entry.startedAt : null,
66
79
  status,
80
+ waitingFor: typeof entry.waitingFor === 'string' ? entry.waitingFor : null,
67
81
  busy,
68
82
  });
69
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",
@@ -36,7 +36,7 @@
36
36
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
37
37
  "build": "npm run clean && tsc -p tsconfig.build.json",
38
38
  "typecheck": "tsc -p tsconfig.json",
39
- "test": "npm run typecheck && node --test \"test/*.test.ts\"",
39
+ "test": "npm run typecheck && node --test --test-timeout=120000 \"test/*.test.ts\"",
40
40
  "fixtures:capture": "node scripts/capture-fixtures.ts",
41
41
  "prepack": "npm run build",
42
42
  "prepublishOnly": "npm run build && TARMAC_REQUIRE_DASH=1 npm test && node dist/cli.js --help > /dev/null"