@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/README.md +87 -116
- package/dist/fleet.js +100 -3
- package/dist/history.js +88 -0
- package/dist/limits.js +62 -0
- package/dist/map.js +10 -1
- package/dist/render.js +655 -25
- package/dist/schema.js +2 -2
- package/dist/server.js +74 -3
- package/dist/sessions.js +15 -1
- package/package.json +2 -2
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
|
-
|
|
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) &&
|
|
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
|
-
|
|
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
|
+
"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"
|