@bli-cockpit/cli 0.2.70 → 0.2.74

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.
@@ -0,0 +1,59 @@
1
+ export function days(ms) {
2
+ return (ms / 86_400_000).toFixed(1);
3
+ }
4
+ /**
5
+ * What the drain did, in the sentence a person reads (BLI-3797).
6
+ *
7
+ * Until this ticket the row said "run `cockpit sync` to deliver them" about
8
+ * objects no sync would ever offer again: the live sync only sends what it
9
+ * collected THIS tick, so 1.8 GB of orphaned evidence sat behind a fix sentence
10
+ * that could not come true. The drain is real now, `--fix` spends it here, and
11
+ * the row reports what it actually managed rather than issuing an instruction.
12
+ */
13
+ export function redeliveryLine(result) {
14
+ if (result.reason === "no_device_session") {
15
+ return "they cannot be re-offered until this machine signs in — run `cockpit login`";
16
+ }
17
+ if (result.reason === "disabled") {
18
+ return "re-delivery is switched off on this machine (COCKPIT_DISABLE_EVIDENCE_REDELIVERY=1)";
19
+ }
20
+ if (result.uploaded > 0 || result.reused > 0) {
21
+ return `re-delivered ${result.uploaded + result.reused} of them (${mib(result.uploaded_bytes)} MB); ${result.deferred} left for the next tick`;
22
+ }
23
+ if (result.failed > 0) {
24
+ return `re-offering them was refused: ${result.failure_reasons.join(", ") || "unknown"} — the next tick retries under backoff`;
25
+ }
26
+ if (result.held > 0) {
27
+ return `${result.held} are held by delivery backoff and retry on a later tick`;
28
+ }
29
+ return "the next `cockpit sync` re-offers them";
30
+ }
31
+ export function diskRowMessage(footprint, capBytes) {
32
+ const staging = footprint.staging;
33
+ const parts = [
34
+ `staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
35
+ ];
36
+ // BLI-3797: "1,916.9 uncommitted" is a number, not a finding. The row now
37
+ // names WHY the biggest share of it has not landed and HOW LONG those bytes
38
+ // have been here — on the reference Mac the answer was
39
+ // `reconciled_unknown_to_server` on 234 objects whose oldest was 45 days old,
40
+ // which is a different problem from a delivery that failed this morning.
41
+ const dominant = staging.dominant_uncommitted_reason;
42
+ if (dominant) {
43
+ parts.push(`oldest undelivered ${days(dominant.oldest_disk_age_ms)}d; biggest reason ${dominant.reason} on ${dominant.count} object(s), ${mib(dominant.bytes)} MB`);
44
+ }
45
+ // BLI-3619's second half: "unknown" means the local ledger's own capped
46
+ // memory cannot say, never that delivery failed — and the one command that
47
+ // actually answers it is named right here, not left for a person to find.
48
+ if (staging.unknown_count > 0) {
49
+ parts.push(`${staging.unknown_count} object(s) unknown to this laptop's own ledger — run \`cockpit clean --reconcile\` to ask the server`);
50
+ }
51
+ parts.push(`logs ${mib(footprint.logs.total_bytes)} MB`, `spool ${mib(footprint.spool_bytes)} MB`);
52
+ for (const vault of footprint.vaults) {
53
+ parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
54
+ }
55
+ return parts.join("; ");
56
+ }
57
+ export function mib(bytes) {
58
+ return (bytes / (1024 * 1024)).toFixed(1);
59
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The `mcp-answers` check (BLI-3804): does the `bli-tower` server this machine
3
+ * REGISTERED actually answer?
4
+ *
5
+ * `memory-registered` (`doctor-registration.ts`) proves the registration —
6
+ * the entry is in `~/.claude.json` and `~/.codex/config.toml` and the bin it
7
+ * names is on this machine. That is a file-contents check, and it stayed green
8
+ * for the whole life of a server that exited 1 on startup on every machine
9
+ * outside the bli-cockpit repo. Nothing on a laptop ever ASKED the server a
10
+ * question, so nothing could tell the difference between registered and
11
+ * working; five QA ticks recorded "no `mcp__bli-tower__*` in this session" as
12
+ * a client-side mystery.
13
+ *
14
+ * This row asks. It spawns the registered server the way an agent host does
15
+ * and calls the cheapest read on it — `docs_list` with `limit: 1`, the same
16
+ * first call `packages/bli-cockpit-mcp/scripts/smoke.ts` makes — and reports
17
+ * what came back.
18
+ *
19
+ * The three outcomes, and why each is spelled the way it is:
20
+ *
21
+ * ok the door answered. The server starts, speaks the protocol, and
22
+ * reaches Tower with this machine's device token.
23
+ * needs_fix the server answered and the DOOR refused, in its own words. The
24
+ * MCP half is alive; the refusal belongs to Tower and is named
25
+ * verbatim. No fix runs from here — the row reports, and `authed`
26
+ * above it already owns the pairing.
27
+ * fail nothing answered: no process, no protocol, no reply. This is the
28
+ * state that hid for five ticks, so it is a `fail` rather than a
29
+ * `needs_fix` — only a `fail` travels to the fleet's install-event
30
+ * ledger with its reason label (`doctor-report.ts`'s `doctorEvent`
31
+ * maps every other status to `ok`).
32
+ *
33
+ * `skipped` when the bin is not on this machine at all, for the same reason
34
+ * `memory-registered` skips: nothing to probe is not a broken machine.
35
+ */
36
+ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
37
+ import { fail, needsFix, ok, skipped } from "./doctor-report.js";
38
+ import { resolveMcpBin } from "./mcp-bin-resolve.js";
39
+ import { probeMcpTool, resolveServerEntry } from "./mcp-stdio-probe.js";
40
+ import { TOWER_DASHBOARD_URL_ENV, TOWER_MCP_BIN } from "./tower-mcp-contract.js";
41
+ /** The cheapest read on the server, and the smoke's own first call. */
42
+ export const MCP_ANSWERS_TOOL = "docs_list";
43
+ const MCP_ANSWERS_ARGUMENTS = { limit: 1 };
44
+ /** Where the server's JS lives behind a Windows `.cmd` shim, relative to `.bin`. */
45
+ const WINDOWS_ENTRY_FROM_BIN = ["..", "@bli-cockpit", "mcp", "dist", "index.js"];
46
+ export async function checkMcpAnswersState(context, deps = {}) {
47
+ const resolveBin = deps.resolveBin ?? resolveMcpBin;
48
+ const resolveEntry = deps.resolveEntry ?? resolveServerEntry;
49
+ const probe = deps.probe ?? probeMcpTool;
50
+ const bin = await resolveBin({
51
+ binName: TOWER_MCP_BIN,
52
+ env: envWithNodeRuntimeOnPath(process.env),
53
+ platform: process.platform,
54
+ });
55
+ if (!bin) {
56
+ return skipped("mcp-answers", "bin_missing", `${TOWER_MCP_BIN} is not installed beside this CLI or on PATH; nothing was asked, and the next daily run will try again`);
57
+ }
58
+ const entry = resolveEntry(bin.path, WINDOWS_ENTRY_FROM_BIN);
59
+ if (!entry) {
60
+ return skipped("mcp-answers", "server_entry_not_found", `${TOWER_MCP_BIN} is registered but its server file could not be found behind the shim; nothing was asked`);
61
+ }
62
+ const outcome = await probe({
63
+ entry,
64
+ toolName: MCP_ANSWERS_TOOL,
65
+ toolArguments: MCP_ANSWERS_ARGUMENTS,
66
+ env: probeEnv(context),
67
+ ...(deps.timeoutMs === undefined ? {} : { timeoutMs: deps.timeoutMs }),
68
+ });
69
+ return mcpAnswersRow(outcome);
70
+ }
71
+ export function mcpAnswersRow(outcome) {
72
+ if (outcome.status === "answered") {
73
+ return ok("mcp-answers", "answered", `bli-tower answered ${MCP_ANSWERS_TOOL} in ${outcome.ms} ms`);
74
+ }
75
+ if (outcome.status === "refused") {
76
+ return needsFix("mcp-answers", outcome.reason, `bli-tower is alive (it answered in ${outcome.ms} ms) and Tower refused ${MCP_ANSWERS_TOOL}`
77
+ + ` (${outcome.reason}): ${outcome.said}`);
78
+ }
79
+ return fail("mcp-answers", outcome.reason, `bli-tower is registered but did not answer ${MCP_ANSWERS_TOOL} (${outcome.reason}): ${outcome.said}.`
80
+ + " No agent session on this machine can reach Tower's tools until it does."
81
+ + " Take the newest CLI with `cockpit update` (the server ships with it), then"
82
+ + " re-register with `cockpit memory install`.");
83
+ }
84
+ /**
85
+ * The child gets a URL and a HOME, never a token: the server reads this
86
+ * machine's device token itself, from the same session file `cockpit login`
87
+ * wrote, which is what makes this a probe of the REAL auth path.
88
+ */
89
+ function probeEnv(context) {
90
+ const base = { ...process.env };
91
+ if (context.command.homeDir) {
92
+ base.HOME = context.command.homeDir;
93
+ base.USERPROFILE = context.command.homeDir;
94
+ }
95
+ if (context.command.dashboardUrl)
96
+ base[TOWER_DASHBOARD_URL_ENV] = context.command.dashboardUrl;
97
+ return base;
98
+ }
@@ -7,9 +7,11 @@ import { describeError } from "../health-detail.js";
7
7
  import { runStagingPrune } from "../disk-prune.js";
8
8
  import { retentionOptionsFromEnv } from "../disk-retention.js";
9
9
  import { readDiskFootprint } from "../disk-usage.js";
10
+ import { runEvidenceRedelivery } from "../evidence-redelivery.js";
10
11
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "../local-state.js";
11
12
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
12
13
  import { runBackfillCommand } from "./backfill.js";
14
+ import { diskRowMessage, mib, redeliveryLine } from "./doctor-disk-words.js";
13
15
  import { doctorRoots } from "./doctor-access.js";
14
16
  import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
15
17
  /**
@@ -126,6 +128,18 @@ export async function checkDiskState(context) {
126
128
  }
127
129
  export async function fixDiskState(context) {
128
130
  const paths = getCollectorRuntimePaths(context.command.homeDir);
131
+ // BLI-3797, BEFORE the prune: the prune can only delete what the ledger
132
+ // vouches for, so on a machine held over the cap by undelivered evidence it
133
+ // frees nothing at all. Deliver first, then sweep what delivering made
134
+ // durable. This never throws and never decides the row's verdict — a drain
135
+ // that could not run leaves the prune to do exactly what it did before.
136
+ const redelivery = await runEvidenceRedelivery({
137
+ homeDir: context.command.homeDir,
138
+ dashboardUrl: context.command.dashboardUrl,
139
+ env: context.io.env,
140
+ fetch: context.io.fetch,
141
+ });
142
+ const redelivered = redeliveryLine(redelivery);
129
143
  const pruned = await runStagingPrune(paths, {
130
144
  env: context.io.env,
131
145
  force: true,
@@ -144,27 +158,7 @@ export async function fixDiskState(context) {
144
158
  // Deliberately still `ok`: staging over the cap because evidence has not been
145
159
  // accepted yet is the collector working, not a machine to repair. The row
146
160
  // names the blockage and the one command that goes further.
147
- return ok("disk-bounded", "staging_cap_blocked_by_uncommitted", `freed ${mib(pruned.deleted_bytes)} MB; ${message}; ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for are holding the rest — run \`cockpit sync\` to deliver them, or \`cockpit clean --all-committed\` to drop every accepted copy now`);
148
- }
149
- function diskRowMessage(footprint, capBytes) {
150
- const staging = footprint.staging;
151
- const parts = [
152
- `staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
153
- ];
154
- // BLI-3619's second half: "unknown" means the local ledger's own capped
155
- // memory cannot say, never that delivery failed — and the one command that
156
- // actually answers it is named right here, not left for a person to find.
157
- if (staging.unknown_count > 0) {
158
- parts.push(`${staging.unknown_count} object(s) unknown to this laptop's own ledger — run \`cockpit clean --reconcile\` to ask the server`);
159
- }
160
- parts.push(`logs ${mib(footprint.logs.total_bytes)} MB`, `spool ${mib(footprint.spool_bytes)} MB`);
161
- for (const vault of footprint.vaults) {
162
- parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
163
- }
164
- return parts.join("; ");
165
- }
166
- function mib(bytes) {
167
- return (bytes / (1024 * 1024)).toFixed(1);
161
+ return ok("disk-bounded", "staging_cap_blocked_by_uncommitted", `freed ${mib(pruned.deleted_bytes)} MB; ${message}; ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for are holding the rest — ${redelivered} or \`cockpit clean --all-committed\` to drop every accepted copy now`);
168
162
  }
169
163
  function capturedIo(io, forward) {
170
164
  const stdoutChunks = [];
@@ -1,5 +1,6 @@
1
1
  import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
2
2
  import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
3
+ import { checkMcpAnswersState } from "./doctor-mcp.js";
3
4
  import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
4
5
  import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
5
6
  import { refreshSetupReceipt } from "./setup-receipt.js";
@@ -23,14 +24,8 @@ export async function runDoctorWithDeps(command, io, deps) {
23
24
  }
24
25
  const canFix = Boolean(invariant.fix) &&
25
26
  (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context));
26
- if (!canFix) {
27
- rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
28
- if (checked.hardStop)
29
- break;
30
- continue;
31
- }
32
- if (!invariant.fix) {
33
- rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
27
+ if (!canFix || !invariant.fix) {
28
+ rows.push(withoutAFix(checked));
34
29
  if (checked.hardStop)
35
30
  break;
36
31
  continue;
@@ -52,6 +47,21 @@ export async function runDoctorWithDeps(command, io, deps) {
52
47
  writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
53
48
  return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
54
49
  }
50
+ /**
51
+ * What a broken row looks like when nothing can repair it from here.
52
+ *
53
+ * A check that says `needs_fix` and has no fix is still `needs_fix` — the
54
+ * person is being told what to do. A check that says `fail` KEEPS that word
55
+ * (BLI-3804): it used to be quietly rewritten to `needs_fix`, which turned the
56
+ * run green and, because `doctor-report.ts` maps every non-`fail` row to `ok`
57
+ * in the install-event ledger, threw the reason label away on the way to the
58
+ * fleet as well. `hardStop` is untouched and still ends the walk.
59
+ */
60
+ function withoutAFix(checked) {
61
+ if (checked.hardStop || checked.status === "fail")
62
+ return checked;
63
+ return { ...checked, status: "needs_fix" };
64
+ }
55
65
  function doctorInvariants() {
56
66
  return [
57
67
  { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
@@ -84,6 +94,16 @@ function doctorInvariants() {
84
94
  check: (context) => context.deps.checkMemory(context),
85
95
  fix: (context, _state) => context.deps.fixMemory(context),
86
96
  },
97
+ // BLI-3804. Right after the registration row, because it asks the second
98
+ // half of the same question: `memory-registered` proves the entry exists
99
+ // and names a bin, this one proves the server behind it starts, speaks the
100
+ // protocol and reaches Tower. Deliberately has NO fix — re-registering a
101
+ // server that is already registered repairs nothing, and the row names the
102
+ // command a person should run instead (`doctor-mcp.ts`).
103
+ {
104
+ id: "mcp-answers",
105
+ check: (context) => context.deps.checkMcpAnswers(context),
106
+ },
87
107
  {
88
108
  id: "backfill-complete",
89
109
  check: (context) => context.deps.checkBackfill(context),
@@ -125,6 +145,7 @@ function defaultDoctorDeps(hooks) {
125
145
  fixAutostart: fixAutostartState,
126
146
  checkMemory: checkMemoryState,
127
147
  fixMemory: fixMemoryState,
148
+ checkMcpAnswers: (context) => checkMcpAnswersState(context),
128
149
  checkBackfill: checkBackfillState,
129
150
  fixBackfill: fixBackfillState,
130
151
  checkGc: checkGcState,
@@ -25,6 +25,7 @@ import os from "node:os";
25
25
  import path from "node:path";
26
26
  import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, memoryInstallGaps, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
27
27
  import { readCachedSetupReceipt } from "./setup-receipt.js";
28
+ import { readStagingInventory } from "../disk-usage.js";
28
29
  import { describeError } from "../health-detail.js";
29
30
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
30
31
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
@@ -111,10 +112,44 @@ export function buildCollectorHeartbeat(options) {
111
112
  ...(typeof options.facts.sessionsPendingUpload === "number"
112
113
  ? { sessions_pending_upload: options.facts.sessionsPendingUpload }
113
114
  : {}),
115
+ ...(typeof options.facts.stagingUncommittedBytes === "number"
116
+ ? { staging_uncommitted_bytes: options.facts.stagingUncommittedBytes }
117
+ : {}),
118
+ ...(options.facts.stagingUncommittedReason
119
+ ? { staging_uncommitted_reason: options.facts.stagingUncommittedReason }
120
+ : {}),
114
121
  ...(options.memoryInstall ? { memory_install: options.memoryInstall } : {}),
115
122
  ...(options.setupReceipt ? { setup_receipt: options.setupReceipt } : {}),
116
123
  };
117
124
  }
125
+ /**
126
+ * What this machine is still holding that never reached storage (BLI-3797).
127
+ *
128
+ * One extra staging walk per tick, on purpose: `cockpit ops` had no way to see
129
+ * that a green, syncing, up-to-date laptop was sitting on 1.8 GB of evidence the
130
+ * server had never received, and a fleet-wide failure nobody can see is the
131
+ * BLI-2528 shape exactly. The walk is bounded by the pack tree the prune keeps
132
+ * under a 2 GB cap, and it is best-effort: a read that fails returns nulls and
133
+ * says so, so the heartbeat omits both fields rather than claiming zero.
134
+ */
135
+ export async function readHeartbeatStagingFacts(options) {
136
+ const paths = getCollectorRuntimePaths(options.homeDir);
137
+ try {
138
+ const inventory = await readStagingInventory(paths, options.now ?? new Date());
139
+ const dominant = inventory.dominant_uncommitted_reason;
140
+ return {
141
+ bytes: inventory.uncommitted_bytes,
142
+ reason: dominant ? `${dominant.reason}:${dominant.count}`.slice(0, 120) : null,
143
+ };
144
+ }
145
+ catch (error) {
146
+ console.error("[heartbeat] the staging tree could not be read; this tick reports no backlog figure", JSON.stringify({
147
+ reason: "staging_inventory_unreadable",
148
+ ...describeError(error),
149
+ }));
150
+ return { bytes: null, reason: null };
151
+ }
152
+ }
118
153
  /**
119
154
  * The cached memory receipt for this tick, or null with a named reason
120
155
  * (BLI-3729).
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Ask an MCP server one question over stdio and see whether it answers
3
+ * (BLI-3804).
4
+ *
5
+ * `cockpit doctor`'s `memory-registered` row proves a server is REGISTERED —
6
+ * that the entry exists in `~/.claude.json` and `~/.codex/config.toml` and
7
+ * points at a bin that is on this machine. It cannot prove the server starts,
8
+ * speaks the protocol, or reaches Tower, and for five QA ticks
9
+ * (`docs/reports/jarvis-qa-loop/tick15.md` … `tick19.md`) nothing else did
10
+ * either: the `bli-tower` tools were shipped, published, and never executed
11
+ * end to end. This module is the missing half — it spawns the server the way
12
+ * an agent host does and calls one cheap read.
13
+ *
14
+ * ## Why a hand-written client
15
+ *
16
+ * MCP's stdio transport is newline-delimited JSON-RPC 2.0 and nothing more:
17
+ * `initialize`, `notifications/initialized`, `tools/call`. Writing those three
18
+ * messages costs ~60 lines here, against pulling
19
+ * `@modelcontextprotocol/sdk` into the collector — the package every intern
20
+ * machine installs — for one diagnostic. The collector already declines a
21
+ * dependency for exactly this reason in `agent-door-session.ts`'s sibling on
22
+ * the MCP side.
23
+ *
24
+ * ## Never a shell
25
+ *
26
+ * The registered bin is an npm shim: a symlink to the server's own JS on
27
+ * POSIX, a `.cmd` on Windows. This module spawns `process.execPath` with the
28
+ * server's JS ENTRY — resolved from the shim, never run through it — so no
29
+ * `cmd.exe`, no quoting hazard, and the same code path on both required host
30
+ * families (`commands/editor.ts` refuses a Windows shim for the same reason).
31
+ * When the entry cannot be found behind the shim the probe says so by name
32
+ * rather than guessing.
33
+ */
34
+ import { spawn } from "node:child_process";
35
+ import fs from "node:fs";
36
+ import path from "node:path";
37
+ const DEFAULT_TIMEOUT_MS = 25_000;
38
+ /**
39
+ * The server's JS entry behind an npm bin shim.
40
+ *
41
+ * POSIX: `node_modules/.bin/<name>` is a symlink to the file itself, so one
42
+ * realpath is the whole answer. Windows: the shim is a `.cmd` and the entry
43
+ * lives at `<.bin>/../<scope>/<pkg>/dist/index.js`, which the caller names.
44
+ */
45
+ export function resolveServerEntry(binPath, windowsFallbackRelative, deps = {}) {
46
+ const realpath = deps.realpath ?? defaultRealpath;
47
+ const exists = deps.exists ?? defaultExists;
48
+ const resolved = realpath(binPath);
49
+ if (resolved.endsWith(".js") || resolved.endsWith(".mjs"))
50
+ return resolved;
51
+ const beside = path.resolve(path.dirname(binPath), ...windowsFallbackRelative);
52
+ return exists(beside) ? beside : null;
53
+ }
54
+ /** One `initialize` + one `tools/call`, then the child is killed. */
55
+ export async function probeMcpTool(request) {
56
+ const now = request.now ?? Date.now;
57
+ const started = now();
58
+ const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
59
+ const spawnProcess = request.spawnProcess ?? defaultSpawn;
60
+ const elapsed = () => now() - started;
61
+ let child;
62
+ try {
63
+ child = spawnProcess(process.execPath, [request.entry], { env: request.env });
64
+ }
65
+ catch (error) {
66
+ return { status: "no_answer", ms: elapsed(), reason: "spawn_failed", said: firstLine(errorText(error)) };
67
+ }
68
+ const pending = new Map();
69
+ /**
70
+ * A reply that arrived before anyone asked for it. Nothing in the protocol
71
+ * forbids it and a stream can deliver one synchronously with the write that
72
+ * provoked it, so the answer is held rather than dropped — a dropped reply
73
+ * would read as a silent server, which is the exact verdict this module
74
+ * exists to make trustworthy.
75
+ */
76
+ const early = new Map();
77
+ let ended = null;
78
+ const endWaiters = [];
79
+ const end = (reason, said) => {
80
+ if (!ended)
81
+ ended = { reason, said };
82
+ for (const wake of endWaiters.splice(0))
83
+ wake();
84
+ };
85
+ child.on("error", ((error) => end("spawn_failed", firstLine(errorText(error)))));
86
+ child.on("exit", ((code) => end("server_exited", `the server exited with code ${code ?? "null"} before answering`)));
87
+ let buffer = "";
88
+ child.stdout?.on("data", (chunk) => {
89
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
90
+ let newline = buffer.indexOf("\n");
91
+ while (newline !== -1) {
92
+ const line = buffer.slice(0, newline).trim();
93
+ buffer = buffer.slice(newline + 1);
94
+ newline = buffer.indexOf("\n");
95
+ if (line === "")
96
+ continue;
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(line);
100
+ }
101
+ catch {
102
+ // Not our protocol. A server that writes prose to stdout has already
103
+ // broken the transport for every real client too, so it is named.
104
+ end("protocol_error", "the server wrote something to stdout that is not JSON-RPC");
105
+ return;
106
+ }
107
+ const message = parsed;
108
+ const id = typeof message["id"] === "number" ? message["id"] : null;
109
+ if (id === null)
110
+ continue;
111
+ const waiter = pending.get(id);
112
+ if (!waiter) {
113
+ early.set(id, message);
114
+ continue;
115
+ }
116
+ pending.delete(id);
117
+ waiter(message);
118
+ }
119
+ });
120
+ const send = (message) => {
121
+ child.stdin?.write(`${JSON.stringify(message)}\n`);
122
+ };
123
+ const awaitReply = (id) => new Promise((resolve) => {
124
+ const alreadyHere = early.get(id);
125
+ if (alreadyHere) {
126
+ early.delete(id);
127
+ resolve(alreadyHere);
128
+ return;
129
+ }
130
+ if (ended) {
131
+ resolve(null);
132
+ return;
133
+ }
134
+ const timer = setTimeout(() => {
135
+ pending.delete(id);
136
+ end("timed_out", `the server did not answer within ${timeoutMs} ms`);
137
+ resolve(null);
138
+ }, timeoutMs);
139
+ timer.unref?.();
140
+ endWaiters.push(() => {
141
+ clearTimeout(timer);
142
+ pending.delete(id);
143
+ resolve(null);
144
+ });
145
+ pending.set(id, (message) => {
146
+ clearTimeout(timer);
147
+ resolve(message);
148
+ });
149
+ });
150
+ try {
151
+ send({
152
+ jsonrpc: "2.0",
153
+ id: 1,
154
+ method: "initialize",
155
+ params: {
156
+ protocolVersion: "2025-06-18",
157
+ capabilities: {},
158
+ clientInfo: { name: "cockpit-doctor", version: "1" },
159
+ },
160
+ });
161
+ const initialized = await awaitReply(1);
162
+ if (!initialized)
163
+ return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
164
+ if (initialized["error"]) {
165
+ return {
166
+ status: "no_answer",
167
+ ms: elapsed(),
168
+ reason: "protocol_error",
169
+ said: firstLine(rpcErrorMessage(initialized)),
170
+ };
171
+ }
172
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
173
+ send({
174
+ jsonrpc: "2.0",
175
+ id: 2,
176
+ method: "tools/call",
177
+ params: { name: request.toolName, arguments: request.toolArguments },
178
+ });
179
+ const called = await awaitReply(2);
180
+ if (!called)
181
+ return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
182
+ if (called["error"]) {
183
+ return {
184
+ status: "no_answer",
185
+ ms: elapsed(),
186
+ reason: "protocol_error",
187
+ said: firstLine(rpcErrorMessage(called)),
188
+ };
189
+ }
190
+ const result = (called["result"] ?? {});
191
+ const said = firstLine((result.content ?? []).find((part) => part.type === "text")?.text ?? "");
192
+ if (result.isError !== true)
193
+ return { status: "answered", ms: elapsed(), said };
194
+ return { status: "refused", ms: elapsed(), reason: refusalReason(said), said };
195
+ }
196
+ finally {
197
+ try {
198
+ child.stdin?.end();
199
+ child.kill();
200
+ }
201
+ catch (error) {
202
+ // The verdict is already decided; a child that will not close changes
203
+ // none of it. Not silent: the reason travels in the caller's log.
204
+ void error;
205
+ }
206
+ }
207
+ }
208
+ /**
209
+ * The DOOR's own reason label, lifted from the sentence the MCP server's
210
+ * `tool-result.ts` built. Deliberately not rephrased — that file's header is
211
+ * explicit that a second wording in a second place is a second thing to keep
212
+ * in step. `packages/bli-cockpit-mcp/scripts/smoke.ts` reads the same shapes
213
+ * for the same reason; the two live in packages that do not depend on each
214
+ * other, which is why the reading exists twice and nothing else does.
215
+ */
216
+ export function refusalReason(said) {
217
+ if (/could not be reached for [^(]*\(/u.test(said))
218
+ return "door_unreachable";
219
+ const refused = /refused [^(]*\(([^)]+)\)/u.exec(said);
220
+ if (refused?.[1])
221
+ return refused[1];
222
+ const unpaired = /not paired with Tower \(([^)]+)\)/u.exec(said);
223
+ if (unpaired?.[1])
224
+ return unpaired[1];
225
+ return "unlabelled_refusal";
226
+ }
227
+ function endedDetail(ended) {
228
+ return ended ?? { reason: "protocol_error", said: "the server stopped answering for an unnamed reason" };
229
+ }
230
+ function rpcErrorMessage(message) {
231
+ const error = message["error"];
232
+ if (error && typeof error === "object") {
233
+ const detail = error;
234
+ if (typeof detail.message === "string")
235
+ return detail.message;
236
+ return `JSON-RPC error ${String(detail.code ?? "?")}`;
237
+ }
238
+ return "JSON-RPC error";
239
+ }
240
+ function defaultSpawn(command, args, options) {
241
+ return spawn(command, args, {
242
+ env: options.env,
243
+ stdio: ["pipe", "pipe", "ignore"],
244
+ });
245
+ }
246
+ function defaultRealpath(value) {
247
+ try {
248
+ return fs.realpathSync.native(value);
249
+ }
250
+ catch {
251
+ return value;
252
+ }
253
+ }
254
+ function defaultExists(file) {
255
+ try {
256
+ return fs.statSync(file).isFile();
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ function errorText(error) {
263
+ return error instanceof Error ? error.message : String(error);
264
+ }
265
+ function firstLine(value) {
266
+ return (value.split("\n")[0] ?? value).slice(0, 240);
267
+ }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.70");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.74");
19
19
  return 0;
20
20
  }
21
21
 
@@ -72,7 +72,14 @@ export async function runStatus(command, io) {
72
72
  writeConnectedBlock(io, setupReceipt);
73
73
  return 0;
74
74
  }
75
- const status = await inspectLocalCollectorStatus(command);
75
+ // The staging walk is opt-in (BLI-3797) and this is the surface that pays for
76
+ // it: one person asking one question. The multi-repo branch above deliberately
77
+ // does not — the backlog is a MACHINE fact, so N repos would walk one tree N
78
+ // times to print the same number.
79
+ const status = await inspectLocalCollectorStatus({
80
+ ...command,
81
+ includeStagingBacklog: true,
82
+ });
76
83
  if (command.json) {
77
84
  writeLine(io.stdout, JSON.stringify({
78
85
  ...status,