@henols/vice-mcp 0.1.4

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/vice-probe.ts ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ // The deliberately-FRAGILE liveness probe -- the counterpart to
3
+ // tools/vice.mjs's resilient withReconnect() ladder, and the two must NEVER
4
+ // be merged. Where the seam retries a transport failure over a ~50s backoff
5
+ // budget because a call has to eventually succeed, this module answers one
6
+ // question fast: is anything actually listening and answering right now.
7
+ //
8
+ // WHAT WENT WRONG BEFORE (this module exists because of it): the host
9
+ // supervisor died, its epoch froze, and the FIRST thing anyone noticed was a
10
+ // call burning ~50s of reconnect backoff before failing -- acquire() had
11
+ // handed out a port based only on the registry CLAIMING an instance existed,
12
+ // never on anything actually answering there (.planning/STATE.md's HOST
13
+ // INSTABILITY / HARD BLOCKER entries, 2026-07-30). Probing four dead
14
+ // candidates through that ladder would take minutes just to report an empty
15
+ // pool -- unacceptable for a health check that has to run on every acquire()
16
+ // poll pass.
17
+ //
18
+ // WHY vice_ping AND ONLY vice_ping: it is the one call measured NON-pausing
19
+ // -- 986,693 cycles/s while ping-polling versus 991,569 fully quiet
20
+ // (.planning/STATE.md's pause-on-read finding, 2026-07-30). Every other
21
+ // state-reading vice_* call PAUSES the emulator and does not resume it,
22
+ // which would make probing itself destructive to the very instance being
23
+ // probed. Check that measurement against STATE.md directly rather than
24
+ // trusting this comment if it ever needs re-verifying.
25
+ //
26
+ // This module takes NO static dependency on tools/vice.mjs (the transport
27
+ // seam) -- importing it is exactly how the resilient retry path would leak
28
+ // into a probe. probeAll() below fans a single-shot request sequence out
29
+ // across several candidate ports at once, none of which the seam is (or
30
+ // should be) pointed at; the seam's module-level "active instance" state has
31
+ // no business being touched by a health check.
32
+ //
33
+ // The one exception (quick-260730-q4b, D-3): a side-effect-only import of
34
+ // repo-root.ts, below. This module is the ONE skill file that imports
35
+ // nothing else from the skill, so without this line the deploy-on-first-use
36
+ // resource check never fires when this probe is the entry point. This does
37
+ // NOT violate the no-dependencies stance above, which is specifically about
38
+ // never importing vice.mjs's resilient retry ladder into this deliberately-
39
+ // fragile probe -- repo-root.ts is a pure path resolver plus this one
40
+ // trigger, and pulls in nothing that speaks MCP.
41
+ import "./repo-root.ts";
42
+
43
+ /** The one and only tool this module will ever call. Hardcoded, not a
44
+ * parameter on any exported function -- that is the structural reason no
45
+ * probe caller can ever steer this module at a forbidden tool (T-p5x-03,
46
+ * D-7): there is no plumbing through which a tool name could arrive. */
47
+ export const PROBE_TOOL = "vice_ping";
48
+
49
+ /** ~1-2s, configurable (D-3). A probe is meant to answer fast; a caller that
50
+ * wants a more patient check (or a stricter one) can override per call. */
51
+ export const DEFAULT_PROBE_TIMEOUT_MS = Number(process.env.VICE_PROBE_TIMEOUT_MS || 1500);
52
+
53
+ /** Options accepted by probeInstance(): a target `url`/`port` pair and an
54
+ * optional per-call timeout override (defaults to DEFAULT_PROBE_TIMEOUT_MS). */
55
+ export interface ProbeInstanceOptions {
56
+ url: string;
57
+ port: number;
58
+ timeoutMs?: number;
59
+ }
60
+
61
+ /** The verdict probeInstance() always resolves to -- never throws, per the
62
+ * function's own doc comment below. `reason` is null exactly when
63
+ * `alive` is true; `ping` carries the decoded vice_ping payload when one was
64
+ * recognisable, otherwise null (or the raw text when it didn't parse as
65
+ * JSON, matching the pre-existing behavior of the function this was ported
66
+ * from). */
67
+ export type ProbeResult = {
68
+ port: number;
69
+ url: string;
70
+ alive: boolean;
71
+ ms: number;
72
+ reason: string | null;
73
+ ping: unknown;
74
+ };
75
+
76
+ /** A single probe target, as probeAll() consumes it -- the caller's own
77
+ * responsibility to have derived `url` from a validated integer `port`
78
+ * (T-p5x-01), never from a string read straight out of a registry file. */
79
+ export interface ProbeInstanceRef {
80
+ port: number;
81
+ url: string;
82
+ }
83
+
84
+ /** Options accepted by probeAll(): the same per-call timeout override
85
+ * probeInstance() takes, applied uniformly to every candidate in the fan-out. */
86
+ export interface ProbeAllOptions {
87
+ timeoutMs?: number;
88
+ }
89
+
90
+ /** The combined result of probeAll(): the plain per-instance results array,
91
+ * plus a `byPort` Map for O(1) lookup by port without re-scanning. */
92
+ export interface ProbeAllResult {
93
+ results: ProbeResult[];
94
+ byPort: Map<number, ProbeResult>;
95
+ }
96
+
97
+ /**
98
+ * Parse an MCP HTTP response body exactly the two ways tools/vice.mjs's
99
+ * rpc() already does: an SSE-framed body's last `data:` line, or a plain
100
+ * JSON body. Deliberately duplicated here, not imported from the seam --
101
+ * see this file's header comment for why sharing that code would mean
102
+ * sharing the seam's module state too. Throws on anything unparseable; the
103
+ * caller turns that into an `alive:false` verdict, never lets it escape.
104
+ */
105
+ function parseMcpBody(text: string, contentType: string): unknown {
106
+ if (contentType.includes("text/event-stream")) {
107
+ const dataLines = text
108
+ .split("\n")
109
+ .filter((l) => l.startsWith("data:"))
110
+ .map((l) => l.slice(5).trim())
111
+ .filter(Boolean);
112
+ if (!dataLines.length) {
113
+ throw new Error("no data: lines in SSE response");
114
+ }
115
+ return JSON.parse(dataLines[dataLines.length - 1]);
116
+ }
117
+ return JSON.parse(text);
118
+ }
119
+
120
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
121
+ * an array. Used to narrow parseMcpBody()'s `unknown` result before reading
122
+ * its `.error`/`.result` fields, in the same style as vice-broker.mts's
123
+ * isPlainObject(). */
124
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
125
+ return typeof value === "object" && value !== null && !Array.isArray(value);
126
+ }
127
+
128
+ /**
129
+ * One single-shot MCP round trip sequence against `url`: the `initialize`
130
+ * handshake followed by a `tools/call` of PROBE_TOOL. A SINGLE
131
+ * `AbortSignal.timeout(timeoutMs)` is created once, before either request,
132
+ * and shared by both -- total wall time is bounded by `timeoutMs` no matter
133
+ * how many round trips the handshake costs. No retry, no backoff, no second
134
+ * attempt at anything: this is the deliberately-fragile counterpart to
135
+ * withReconnect(), and reusing that ladder here would silently reintroduce
136
+ * the ~50s-per-dead-candidate problem this module exists to avoid (D-3).
137
+ *
138
+ * NEVER THROWS. Every failure mode -- a rejected fetch, an abort, a non-2xx
139
+ * status, a JSON-RPC `error` member, or a 200 that doesn't decode to a
140
+ * recognisable ping result -- becomes `{ alive: false, reason }` instead, so
141
+ * a caller can always build a per-candidate rejection report without a
142
+ * try/catch of its own.
143
+ *
144
+ * Returns `{ port, url, alive, ms, reason, ping }`.
145
+ */
146
+ export async function probeInstance({
147
+ url,
148
+ port,
149
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
150
+ }: ProbeInstanceOptions): Promise<ProbeResult> {
151
+ const startedAt = Date.now();
152
+ const elapsed = () => Date.now() - startedAt;
153
+ const signal = AbortSignal.timeout(timeoutMs);
154
+
155
+ const post = async (body: unknown) =>
156
+ fetch(url, {
157
+ method: "POST",
158
+ headers: {
159
+ "Content-Type": "application/json",
160
+ Accept: "application/json, text/event-stream",
161
+ },
162
+ body: JSON.stringify(body),
163
+ signal,
164
+ });
165
+
166
+ try {
167
+ const initRes = await post({
168
+ jsonrpc: "2.0",
169
+ id: 1,
170
+ method: "initialize",
171
+ params: {
172
+ protocolVersion: "2024-11-05",
173
+ capabilities: {},
174
+ clientInfo: { name: "bruce-lee-probe", version: "1.0" },
175
+ },
176
+ });
177
+ if (!initRes.ok) {
178
+ return { port, url, alive: false, ms: elapsed(), reason: `initialize returned HTTP ${initRes.status}`, ping: null };
179
+ }
180
+ let initPayload: unknown;
181
+ try {
182
+ initPayload = parseMcpBody(await initRes.text(), initRes.headers.get("content-type") || "");
183
+ } catch (e) {
184
+ return { port, url, alive: false, ms: elapsed(), reason: `initialize: unparseable response (${(e as Error).message})`, ping: null };
185
+ }
186
+ if (isPlainObject(initPayload) && initPayload.error) {
187
+ const initError = initPayload.error;
188
+ const initErrorMessage = isPlainObject(initError) && typeof initError.message === "string" ? initError.message : "unknown";
189
+ return {
190
+ port, url, alive: false, ms: elapsed(),
191
+ reason: `initialize RPC error: ${initErrorMessage}`,
192
+ ping: null,
193
+ };
194
+ }
195
+
196
+ const callRes = await post({
197
+ jsonrpc: "2.0",
198
+ id: 2,
199
+ method: "tools/call",
200
+ params: { name: PROBE_TOOL, arguments: {} },
201
+ });
202
+ if (!callRes.ok) {
203
+ return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL} returned HTTP ${callRes.status}`, ping: null };
204
+ }
205
+ let callPayload: unknown;
206
+ try {
207
+ callPayload = parseMcpBody(await callRes.text(), callRes.headers.get("content-type") || "");
208
+ } catch (e) {
209
+ return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL}: unparseable response (${(e as Error).message})`, ping: null };
210
+ }
211
+ if (isPlainObject(callPayload) && callPayload.error) {
212
+ const callError = callPayload.error;
213
+ const callErrorMessage = isPlainObject(callError) && typeof callError.message === "string" ? callError.message : "unknown";
214
+ return {
215
+ port, url, alive: false, ms: elapsed(),
216
+ reason: `${PROBE_TOOL} RPC error: ${callErrorMessage}`,
217
+ ping: null,
218
+ };
219
+ }
220
+
221
+ const result = isPlainObject(callPayload) ? callPayload.result : undefined;
222
+ const contentArray = isPlainObject(result) && Array.isArray(result.content) ? result.content : undefined;
223
+ const content = contentArray ? contentArray[0] : undefined;
224
+ if (!isPlainObject(content) || content.type !== "text") {
225
+ return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL}: unexpected tool result shape`, ping: null };
226
+ }
227
+ let ping: unknown;
228
+ try {
229
+ ping = JSON.parse(content.text as string);
230
+ } catch {
231
+ ping = content.text;
232
+ }
233
+ // A 200 that doesn't decode to a recognisable ping result (no "version"
234
+ // field) is something ELSE listening on that port -- not the same as
235
+ // VICE being up (T-p5x-04).
236
+ if (!isPlainObject(ping) || typeof ping.version === "undefined") {
237
+ return {
238
+ port, url, alive: false, ms: elapsed(),
239
+ reason: `something answered on this port but did not return a recognisable ping result (no "version" field) -- not VICE`,
240
+ ping: ping ?? null,
241
+ };
242
+ }
243
+
244
+ return { port, url, alive: true, ms: elapsed(), reason: null, ping };
245
+ } catch (e) {
246
+ const ms = elapsed();
247
+ const err = e as Error & { name?: string; cause?: unknown };
248
+ if (err.name === "TimeoutError" || err.name === "AbortError") {
249
+ return { port, url, alive: false, ms, reason: `no response within ${timeoutMs}ms (timeout)`, ping: null };
250
+ }
251
+ // Surface the underlying cause code (ECONNREFUSED etc.) when fetch's
252
+ // undici implementation attaches one, rather than a generic message --
253
+ // "nothing is listening" and "something is wrong with the request" are
254
+ // different diagnoses and the reason string should say which.
255
+ const cause = err.cause;
256
+ const causeCode = isPlainObject(cause) && typeof cause.code === "string" ? cause.code : undefined;
257
+ return { port, url, alive: false, ms, reason: causeCode || err.message, ping: null };
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Probe every instance in `instances` concurrently -- N candidates cost one
263
+ * timeout, not N (D-3). Takes instance OBJECTS (`{ port, url }`), never raw
264
+ * registry entries: the caller is responsible for having derived `url` from
265
+ * a validated integer port (T-p5x-01), never from a string read out of a
266
+ * registry file. Returns both the plain results array and a `byPort` Map so
267
+ * callers can look a verdict up without re-scanning.
268
+ */
269
+ export async function probeAll(
270
+ instances: ProbeInstanceRef[],
271
+ { timeoutMs = DEFAULT_PROBE_TIMEOUT_MS }: ProbeAllOptions = {}
272
+ ): Promise<ProbeAllResult> {
273
+ const results = await Promise.all(
274
+ instances.map((inst) => probeInstance({ url: inst.url, port: inst.port, timeoutMs }))
275
+ );
276
+ const byPort = new Map(results.map((r) => [r.port, r]));
277
+ return { results, byPort };
278
+ }