heyspex 0.2.5 → 0.2.7

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.
Files changed (110) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +349 -38
  3. package/bin/spexcode.mjs +31 -0
  4. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  5. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  6. package/dev-factory/lib/runner-path.mjs +51 -0
  7. package/dev-factory/lib/seat-context.mjs +20 -4
  8. package/dev-factory/model-catalog.json +222 -222
  9. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  10. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  11. package/dist/cli/hs-connect.mjs +40 -3
  12. package/dist/cli/hs-defer.mjs +3817 -3741
  13. package/dist/cli/hs-events.mjs +1296 -0
  14. package/dist/cli/hs-export-events.mjs +1252 -0
  15. package/dist/cli/hs-handoff.mjs +2576 -0
  16. package/dist/cli/hs-index-rebuild.mjs +228 -37
  17. package/dist/cli/hs-index-reconcile.mjs +231 -40
  18. package/dist/cli/hs-init.mjs +22 -3
  19. package/dist/cli/hs-install.mjs +22 -3
  20. package/dist/cli/hs-join.mjs +40 -3
  21. package/dist/cli/hs-next-launch.mjs +2777 -0
  22. package/dist/cli/hs-pin.mjs +1336 -0
  23. package/dist/cli/hs-prereq.mjs +1380 -0
  24. package/dist/cli/hs-revalidate.mjs +3257 -0
  25. package/dist/cli/hs-spawn.mjs +12403 -0
  26. package/dist/cli/hs-test.mjs +1046 -0
  27. package/dist/cli/hs-trash.mjs +362 -0
  28. package/dist/cli/hs-trust.mjs +197 -0
  29. package/dist/cli/hs-vault-pull.mjs +233 -42
  30. package/dist/cli/hs-vault-push.mjs +231 -40
  31. package/dist/cli/hs-vault.mjs +21 -7
  32. package/dist/cli/hs-workspace.mjs +19 -1
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  42. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  43. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  44. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  45. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  46. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  47. package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
  48. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  49. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  50. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  51. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  52. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  53. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  54. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  55. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  56. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  57. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  58. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  59. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  60. package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
  61. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  62. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  63. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  64. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  65. package/docs/copilot-plugin-install.md +9 -2
  66. package/docs/mcp-client-config.md +6 -0
  67. package/package.json +266 -257
  68. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  69. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  70. package/plugins/heyspex/README.md +6 -0
  71. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  72. package/plugins/heyspex/hooks/hooks.json +418 -418
  73. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  74. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  75. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  76. package/scripts/hs-companion.mjs +41 -3
  77. package/scripts/install-claude-code-hooks.mjs +104 -3
  78. package/scripts/install-copilot-hooks.mjs +21 -1
  79. package/scripts/install-cursor-hooks.mjs +4 -3
  80. package/scripts/lib/capture-execution-guard.mjs +221 -0
  81. package/scripts/lib/companion-install.mjs +768 -0
  82. package/scripts/lib/customer-kit.mjs +53 -3
  83. package/scripts/lib/hs-companion-settings.mjs +26 -0
  84. package/scripts/lib/hs-source-push.mjs +386 -386
  85. package/scripts/record-grounding.mjs +38 -0
  86. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  87. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  88. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  90. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  91. package/spexharness/src/seat-context.mjs +10 -5
  92. package/spexharness/src/session-bootstrap.mjs +6 -3
  93. package/vendor/heyspex-mcp/cli.mjs +6 -1
  94. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  95. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  96. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  97. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  98. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  99. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  100. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  101. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  102. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  103. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  104. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  105. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  106. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  107. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  108. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  109. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
  110. package/bin/postinstall.mjs +0 -88
@@ -0,0 +1,755 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * firecrawl-cli.mjs — CLI wrapper around firecrawl-adapter.mjs so a Claude subagent (Bash tool
4
+ * only, no JS imports) can use self-hosted Firecrawl for search/scrape.
5
+ *
6
+ * THE GAP THIS CLOSES: `.heyspex/config.yaml` (agent_behaviors.research.default_source) and
7
+ * specs/protocols/research-capture-protocol.md declare self-hosted Firecrawl on Night Action the
8
+ * default search/scrape backend for every rung of the confidence ladder. But the ladder workflows
9
+ * (.claude/workflows/deep-research-{quick,medium,thorough}.js) dispatch Claude *subagents* whose
10
+ * prompts said "use WebSearch" / "use WebFetch" — subagents can't `import` firecrawl-adapter.mjs,
11
+ * so the claimed default was silently bypassed by every real run. This CLI is the bridge: the
12
+ * subagent prompts now say "try this CLI first"; this file does the actual Firecrawl call.
13
+ *
14
+ * SHIPPED IN THE `heyspex` NPM PACKAGE (owner ruling 2026-10-17): in a customer repo
15
+ * `node node_modules/heyspex/dev-factory/spexcrawl/firecrawl-cli.mjs search|scrape …` finds the
16
+ * self-hosted Firecrawl in the user's LOCAL Docker with zero config — see loadPlatform() below and
17
+ * dev-factory/lib/local-firecrawl.mjs. No env var, no API key, never hosted.
18
+ *
19
+ * HARD RULES (see [[firecrawl-routing-self-hosted-night]] — do not relax these):
20
+ * 1. NEVER spend hosted api.firecrawl.dev credits from this CLI. If the endpoint
21
+ * firecrawl-adapter.mjs's getBaseUrl() resolves is the hosted host, REFUSE outright — exit
22
+ * non-zero before any network call. Only the owner decides hosted spend, interactively.
23
+ * 2. Ollama has priority on the Night Action mode lease — Firecrawl work WAITS, never evicts.
24
+ * Before its first call this CLI asks for the lease via requestFirecrawlMode() — the SAME
25
+ * function every other caller uses. That function already refuses to touch anything when
26
+ * Ollama holds the box and only starts the stack over SSH when the box is genuinely idle.
27
+ * This CLI does not duplicate or bypass that logic.
28
+ * 3. On refusal/unreachable, print a single machine-readable line `FIRECRAWL_UNAVAILABLE: <reason>`
29
+ * and exit non-zero, so a calling ladder-workflow prompt ("if it prints FIRECRAWL_UNAVAILABLE
30
+ * or fails, fall back to WebSearch/WebFetch") can react deterministically instead of hanging
31
+ * or silently returning nothing. Every path is caught — never a raw stack trace.
32
+ *
33
+ * Usage:
34
+ * node dev-factory/spexcrawl/firecrawl-cli.mjs search "<query>" [--limit N] [--json]
35
+ * node dev-factory/spexcrawl/firecrawl-cli.mjs scrape "<url>" [--json]
36
+ * node dev-factory/spexcrawl/firecrawl-cli.mjs --self-test
37
+ *
38
+ * Exit codes: 0 ok · 1 unexpected error (clean message, no stack) · 2 refused (hosted endpoint,
39
+ * policy violation) · 3 FIRECRAWL_UNAVAILABLE (lease refused / unreachable / network
40
+ * failure — the fallback-to-WebSearch/WebFetch signal) · 64 usage error (bad args).
41
+ */
42
+ import { readFileSync } from "node:fs";
43
+ import { spawnSync } from "node:child_process";
44
+ import { fileURLToPath } from "node:url";
45
+ import { OLLAMA } from "../lib/model-catalog.mjs";
46
+ import {
47
+ detectLocalFirecrawl,
48
+ isHostedFirecrawlUrl as isHostedFirecrawl,
49
+ localFirecrawlScrape,
50
+ localFirecrawlSearch,
51
+ LOCAL_FIRECRAWL_SETUP_HINT,
52
+ } from "../lib/local-firecrawl.mjs";
53
+
54
+ // ── two modes: platform checkout vs. the `heyspex` npm package ───────────────────────────────────
55
+ /**
56
+ * This CLI ships in the `heyspex` npm package (owner ruling 2026-10-17: customer research finds
57
+ * URLs through a self-hosted Firecrawl in the user's LOCAL Docker, zero config). The SpexFleet
58
+ * routing it uses in a platform checkout — firecrawl-adapter.mjs (fleet candidates, cost ledger)
59
+ * and night-action-mode.mjs (the Ollama/Firecrawl mode lease) — is NOT in the package, so it is
60
+ * loaded with a dynamic import that a published install answers with "absent":
61
+ *
62
+ * platform both modules load → fleet candidates + lease, then this machine's Docker, then
63
+ * the on-demand/remote autostarts
64
+ * local either is missing → detectLocalFirecrawl() only (optional override env,
65
+ * `docker ps`, localhost:3002), plain HTTP, no API key, never hosted
66
+ *
67
+ * Only a genuinely missing module selects local mode; any other load error is rethrown.
68
+ */
69
+ let platformPromise = null;
70
+ let loadedPlatform = null;
71
+ export function loadPlatform() {
72
+ if (!platformPromise) {
73
+ platformPromise = Promise.all([import("./firecrawl-adapter.mjs"), import("../lib/night-action-mode.mjs")])
74
+ .then(([adapter, mode]) => (loadedPlatform = { adapter, mode }))
75
+ .catch((e) => {
76
+ if (e?.code === "ERR_MODULE_NOT_FOUND") return null;
77
+ throw e;
78
+ });
79
+ }
80
+ return platformPromise;
81
+ }
82
+
83
+ export const MAX_SCRAPE_CHARS = 20000;
84
+
85
+ export const HOSTED_REFUSAL_MESSAGE =
86
+ "REFUSED: resolved Firecrawl endpoint is the hosted api.firecrawl.dev API. Agents must NEVER " +
87
+ "spend hosted credits — only the owner decides, and only interactively (see " +
88
+ "specs/memory/decisions/firecrawl-routing-self-hosted-night.md). This CLI only ever talks to a " +
89
+ "self-hosted Firecrawl endpoint.";
90
+
91
+ // ── on-demand local stack (fleet-down fallback) ────────────────────────────────────────────────
92
+ /**
93
+ * Does THIS machine declare an ON-DEMAND self-hosted Firecrawl? Returns its base URL, or null.
94
+ *
95
+ * On-demand hosts are normally STOPPED ([[workstation-firecrawl-is-the-fleet-down]]) — the owner
96
+ * works on that box and the stack idles ~6 containers. So a probe failing against it is the
97
+ * EXPECTED resting state, not a broken install, and it is worth starting before giving up.
98
+ * Opt-in is explicit: `services.firecrawl.onDemand === true` on the machine named by
99
+ * HEYSPEX_FLEET_MACHINE_KEY. Any other host is left alone.
100
+ */
101
+ export function localOnDemandFirecrawl(env = process.env) {
102
+ try {
103
+ const key = String(env.HEYSPEX_FLEET_MACHINE_KEY || "").trim();
104
+ if (!key) return null;
105
+ const table = JSON.parse(readFileSync(fileURLToPath(new URL("../routing-table.json", import.meta.url)), "utf8"));
106
+ const svc = table?.machines?.[key]?.services?.firecrawl;
107
+ if (!svc || svc.onDemand !== true) return null;
108
+ const baseUrl = String(svc.localUrl || svc.url || "").trim().replace(/\/+$/, "");
109
+ if (!baseUrl || isHostedFirecrawl(baseUrl)) return null;
110
+ return baseUrl;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Start the local on-demand stack and return its base URL, or null if there is nothing to start
118
+ * or the start failed. NEVER throws — a failure here must degrade to the original
119
+ * FIRECRAWL_UNAVAILABLE, never to a crash.
120
+ */
121
+ export async function autostartLocalFirecrawl({ env = process.env, spawn = spawnSync } = {}) {
122
+ const baseUrl = localOnDemandFirecrawl(env);
123
+ if (!baseUrl) return null;
124
+ try {
125
+ const script = fileURLToPath(new URL("../scripts/firecrawl-local.sh", import.meta.url));
126
+ const res = spawn("bash", [script, "up"], { timeout: 300000, stdio: "ignore", encoding: "utf8" });
127
+ return res && res.status === 0 ? baseUrl : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ // ── remote start (fleet machines with a declared day-2 SSH script) ────────────────────────────
134
+ /**
135
+ * Fleet machines other than "this one" that declare `services.firecrawl.remoteStart.script` —
136
+ * a repo-relative day-2 script (mirroring firecrawl-local.sh's {status|up|down} contract) that
137
+ * SSHes out and brings the stack up on THAT machine, e.g. dev-factory/scripts/firecrawl-specops.sh.
138
+ *
139
+ * WHY THIS EXISTS (HS-DEC firecrawl-any-machine-docker-autostart, 2026-08-25): before this,
140
+ * `ensureAvailable()`'s only autostart path was `autostartLocalFirecrawl()`, which only ever acts
141
+ * on the machine THIS process is running on (`HEYSPEX_FLEET_MACHINE_KEY`). A remote fleet host
142
+ * with its own working day-2 script (Spec-ops) still read as FIRECRAWL_UNAVAILABLE and required a
143
+ * human to notice the script existed and run it by hand — exactly the gap this closes.
144
+ *
145
+ * Ordered by FLEET_FIRECRAWL_PREFERENCE so Spec-ops (the fleet default, owner ruling 2026-08-25)
146
+ * is tried before Basement Geek. Basement Geek has no verified remote-start script yet (its SSH
147
+ * shell mixed bash/cmd.exe output on a first probe — do not fabricate one untested); it simply
148
+ * has no `remoteStart` entry, so it is skipped here, same as any machine without one.
149
+ */
150
+ export function remoteStartCandidates(env = process.env, preference = loadedPlatform?.adapter?.FLEET_FIRECRAWL_PREFERENCE ?? []) {
151
+ const out = [];
152
+ try {
153
+ const key = String(env.HEYSPEX_FLEET_MACHINE_KEY || "").trim();
154
+ const table = JSON.parse(readFileSync(fileURLToPath(new URL("../routing-table.json", import.meta.url)), "utf8"));
155
+ const machines = table?.machines || {};
156
+ const seenScripts = new Set();
157
+ const order = [...preference, ...Object.keys(machines).filter((k) => k !== "_comment" && !preference.includes(k))];
158
+ for (const machineKey of order) {
159
+ if (machineKey === key) continue; // this machine uses autostartLocalFirecrawl instead
160
+ const svc = machines[machineKey]?.services?.firecrawl;
161
+ const scriptRel = svc?.remoteStart?.script;
162
+ if (!scriptRel || typeof scriptRel !== "string") continue;
163
+ if (seenScripts.has(scriptRel)) continue; // specops-gpu0/gpu1 share one script/stack
164
+ seenScripts.add(scriptRel);
165
+ const baseUrl = String(svc.url || "").trim().replace(/\/+$/, "");
166
+ if (!baseUrl || isHostedFirecrawl(baseUrl)) continue;
167
+ out.push({ machineKey, scriptPath: scriptRel, baseUrl });
168
+ }
169
+ } catch {
170
+ return [];
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * Try each declared remote-start script in turn (repo-relative, resolved from this file's
177
+ * location); return the first one that starts successfully, or null. NEVER throws — same
178
+ * fail-safe contract as autostartLocalFirecrawl.
179
+ */
180
+ export async function autostartRemoteFirecrawl({ env = process.env, spawn = spawnSync, candidatesFn = remoteStartCandidates } = {}) {
181
+ const candidates = typeof candidatesFn === "function" ? candidatesFn(env) : [];
182
+ for (const { scriptPath, baseUrl } of candidates) {
183
+ try {
184
+ const script = fileURLToPath(new URL(`../../${scriptPath}`, import.meta.url));
185
+ const res = spawn("bash", [script, "up"], { timeout: 300000, stdio: "ignore", encoding: "utf8" });
186
+ if (res && res.status === 0) return baseUrl;
187
+ } catch {
188
+ // try the next candidate — never throw
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ // ── the single availability gate every subcommand runs first — never throws ─────────────────────
195
+ /**
196
+ * @returns {Promise<
197
+ * {ok:true, baseUrl:string} |
198
+ * {ok:false, refusedHosted:true, message:string} |
199
+ * {ok:false, unavailable:true, reason:string}
200
+ * >}
201
+ */
202
+ export async function ensureAvailable(opts = {}) {
203
+ const platform = opts.platform !== undefined ? opts.platform : await loadPlatform();
204
+ return platform ? ensurePlatformAvailable(platform, opts) : ensureLocalAvailable(opts);
205
+ }
206
+
207
+ /**
208
+ * npm-package mode: the local Docker Firecrawl or nothing. No env var required, no API key, never
209
+ * hosted. Unavailable carries the plain setup steps (start Docker Desktop + the Firecrawl stack).
210
+ */
211
+ export async function ensureLocalAvailable({ localDetectFn = detectLocalFirecrawl, modeOpts = {}, env = process.env } = {}) {
212
+ let det;
213
+ try {
214
+ det = await localDetectFn({ env, ...(modeOpts.httpFetch ? { httpFetch: modeOpts.httpFetch } : {}) });
215
+ } catch (e) {
216
+ det = { ok: false, reason: `local Firecrawl detection failed: ${e?.message || e}` };
217
+ }
218
+ if (det?.ok && det.baseUrl && !isHostedFirecrawl(det.baseUrl)) {
219
+ return { ok: true, baseUrl: det.baseUrl, mode: "local", source: det.source };
220
+ }
221
+ return { ok: false, unavailable: true, mode: "local", reason: `${det?.reason || "no local Firecrawl found"}. ${LOCAL_FIRECRAWL_SETUP_HINT}` };
222
+ }
223
+
224
+ /** Platform-mode lookup of this machine's own Docker; null when nothing local answers. Never throws. */
225
+ async function tryLocalDocker(localDetectFn, modeOpts) {
226
+ if (typeof localDetectFn !== "function") return null;
227
+ try {
228
+ const det = await localDetectFn(modeOpts?.httpFetch ? { httpFetch: modeOpts.httpFetch } : {});
229
+ return det?.ok && det.baseUrl && !isHostedFirecrawl(det.baseUrl) ? det.baseUrl : null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ async function ensurePlatformAvailable(platform, opts = {}) {
236
+ const {
237
+ getBaseUrlFn = platform.adapter.getBaseUrl,
238
+ modeOpts = {},
239
+ candidatesFn = platform.adapter.listSelfHostedFirecrawlCandidates,
240
+ autostartFn = autostartLocalFirecrawl,
241
+ remoteStartFn = autostartRemoteFirecrawl,
242
+ localDetectFn = detectLocalFirecrawl,
243
+ } = opts;
244
+ const { requestFirecrawlMode, resolveLocalFirecrawlProbeUrl } = platform.mode;
245
+ let primary;
246
+ try {
247
+ primary = getBaseUrlFn();
248
+ } catch (e) {
249
+ return { ok: false, unavailable: true, reason: `could not resolve Firecrawl base URL: ${e?.message || e}` };
250
+ }
251
+ if (isHostedFirecrawl(primary)) {
252
+ // Prefer a fleet self-hosted duplicate before refusing — Basement / Spec-ops may be up
253
+ // even when FIRECRAWL_BASE_URL still points at the hosted default.
254
+ const fleet = typeof candidatesFn === "function" ? candidatesFn() : [];
255
+ if (!fleet.length) {
256
+ // A Firecrawl in this machine's own Docker is self-hosted too — use it before refusing.
257
+ const local = await tryLocalDocker(localDetectFn, modeOpts);
258
+ if (local) return { ok: true, baseUrl: local, source: "local-docker" };
259
+ return { ok: false, refusedHosted: true, message: HOSTED_REFUSAL_MESSAGE };
260
+ }
261
+ }
262
+
263
+ const candidates = [];
264
+ const seen = new Set();
265
+ const push = (url) => {
266
+ const u = typeof url === "string" ? url.trim().replace(/\/+$/, "") : "";
267
+ if (!u || isHostedFirecrawl(u) || seen.has(u)) return;
268
+ seen.add(u);
269
+ candidates.push(u);
270
+ };
271
+ push(primary);
272
+ if (typeof candidatesFn === "function") {
273
+ for (const u of candidatesFn()) push(u);
274
+ }
275
+ if (!candidates.length) {
276
+ return { ok: false, refusedHosted: true, message: HOSTED_REFUSAL_MESSAGE };
277
+ }
278
+
279
+ // Try each self-hosted host (Basement OR Spec-ops OR Night Action). Unleased hosts skip the
280
+ // XOR lease and just probe; leased Night Action keeps the SSH/lease path.
281
+ const reasons = [];
282
+ for (const baseUrl of candidates) {
283
+ let lease;
284
+ try {
285
+ lease = await requestFirecrawlMode({
286
+ localProbeUrl: resolveLocalFirecrawlProbeUrl({ FIRECRAWL_BASE_URL: baseUrl }),
287
+ ...modeOpts,
288
+ });
289
+ } catch (e) {
290
+ reasons.push(`${baseUrl}: mode-lease request failed: ${e?.message || e}`);
291
+ continue;
292
+ }
293
+ if (lease.granted) return { ok: true, baseUrl };
294
+ reasons.push(`${baseUrl}: ${lease.reason}`);
295
+ }
296
+
297
+ // Every declared candidate is down — a Firecrawl in THIS machine's Docker (any compose project,
298
+ // any published port) is still self-hosted. Found by `docker ps` + probe, no config needed.
299
+ {
300
+ const local = await tryLocalDocker(localDetectFn, modeOpts);
301
+ if (local) return { ok: true, baseUrl: local, source: "local-docker" };
302
+ }
303
+
304
+ // LAST RESort — every candidate is down (the fleet is powered off). If THIS machine declares an
305
+ // on-demand stack, start it and retry once. Deliberately placed after the loop so it can only
306
+ // ever turn a failure into a success: on any error we fall through to the original unavailable
307
+ // result below, and the "stop, never substitute" contract is preserved (a started local stack is
308
+ // still a self-hosted endpoint — not a hosted-credit spend and not a WebSearch fallback).
309
+ if (typeof autostartFn === "function") {
310
+ let started = null;
311
+ try {
312
+ started = await autostartFn();
313
+ } catch {
314
+ started = null;
315
+ }
316
+ if (started) {
317
+ try {
318
+ const lease = await requestFirecrawlMode({
319
+ localProbeUrl: resolveLocalFirecrawlProbeUrl({ FIRECRAWL_BASE_URL: started }),
320
+ ...modeOpts,
321
+ });
322
+ if (lease.granted) return { ok: true, baseUrl: started, autostarted: true };
323
+ reasons.push(`${started}: autostarted but lease refused — ${lease.reason}`);
324
+ } catch (e) {
325
+ reasons.push(`${started}: autostarted but probe failed: ${e?.message || e}`);
326
+ }
327
+ }
328
+ }
329
+
330
+ // SECOND LAST RESORT — this machine has nothing on-demand of its own, but a fleet machine
331
+ // (Spec-ops) may declare a working day-2 SSH script to start ITS stack remotely. Same fail-safe
332
+ // shape as the local autostart above: only ever turns a failure into a success.
333
+ if (typeof remoteStartFn === "function") {
334
+ let started = null;
335
+ try {
336
+ started = await remoteStartFn();
337
+ } catch {
338
+ started = null;
339
+ }
340
+ if (started) {
341
+ try {
342
+ const lease = await requestFirecrawlMode({
343
+ localProbeUrl: resolveLocalFirecrawlProbeUrl({ FIRECRAWL_BASE_URL: started }),
344
+ ...modeOpts,
345
+ });
346
+ if (lease.granted) return { ok: true, baseUrl: started, autostarted: true };
347
+ reasons.push(`${started}: remote-started but lease refused — ${lease.reason}`);
348
+ } catch (e) {
349
+ reasons.push(`${started}: remote-started but probe failed: ${e?.message || e}`);
350
+ }
351
+ }
352
+ }
353
+
354
+ return { ok: false, unavailable: true, reason: reasons.join(" | ") || "no self-hosted Firecrawl endpoint available" };
355
+ }
356
+
357
+ // ── subcommand implementations (testable independent of the CLI/exit layer) ─────────────────────
358
+ export async function runSearch(query, { limit = 6, httpFetch, availabilityOpts = {} } = {}) {
359
+ const platform = availabilityOpts.platform !== undefined ? availabilityOpts.platform : await loadPlatform();
360
+ const avail = await ensureAvailable({ ...availabilityOpts, platform });
361
+ if (!avail.ok) return avail;
362
+ if (!platform) {
363
+ const lr = await localFirecrawlSearch(avail.baseUrl, query, { limit, ...(httpFetch ? { httpFetch } : {}) });
364
+ if (!lr.ok) return { ok: false, unavailable: true, reason: `search request failed on ${avail.baseUrl}: ${lr.error}` };
365
+ return { ok: true, results: lr.results, servedBy: avail.baseUrl, autostarted: false };
366
+ }
367
+ let r;
368
+ try {
369
+ r = await platform.adapter.firecrawlSearch(query, { limit, baseUrl: avail.baseUrl, httpFetch });
370
+ } catch (e) {
371
+ return { ok: false, unavailable: true, reason: `search request failed: ${e?.message || e}` };
372
+ }
373
+ if (r.blocked) return { ok: false, unavailable: true, reason: r.reason };
374
+ if (r.error) return { ok: false, unavailable: true, reason: r.error };
375
+ // servedBy: which candidate ACTUALLY answered. A research doc's Method block should record the
376
+ // real backend, not the intended one — the failover walks several hosts and an on-demand local
377
+ // stack may be the one that served.
378
+ return { ok: true, results: r.results, servedBy: avail.baseUrl, autostarted: avail.autostarted === true };
379
+ }
380
+
381
+ export async function runScrape(url, { httpFetch, availabilityOpts = {}, maxChars = MAX_SCRAPE_CHARS } = {}) {
382
+ const platform = availabilityOpts.platform !== undefined ? availabilityOpts.platform : await loadPlatform();
383
+ const avail = await ensureAvailable({ ...availabilityOpts, platform });
384
+ if (!avail.ok) return avail;
385
+ let r;
386
+ try {
387
+ r = platform
388
+ ? await platform.adapter.firecrawlScrape(url, { baseUrl: avail.baseUrl, httpFetch })
389
+ : await localFirecrawlScrape(avail.baseUrl, url, httpFetch ? { httpFetch } : {});
390
+ } catch (e) {
391
+ return { ok: false, unavailable: true, reason: `scrape request failed: ${e?.message || e}` };
392
+ }
393
+ if (r.blocked) return { ok: false, unavailable: true, reason: r.reason };
394
+ if (!r.ok) return { ok: false, unavailable: true, reason: r.error || `HTTP ${r.status || 0}` };
395
+ const full = r.markdown || "";
396
+ const truncated = full.length > maxChars;
397
+ const markdown = truncated ? full.slice(0, maxChars) : full;
398
+ return { ok: true, url, title: r.title || "", markdown, truncated, fullLength: full.length, servedBy: avail.baseUrl, autostarted: avail.autostarted === true };
399
+ }
400
+
401
+ // ── output formatting ─────────────────────────────────────────────────────────────────────────
402
+ function formatSearchList(results) {
403
+ if (results.length === 0) return "(no results)";
404
+ return results
405
+ .map((r, i) => {
406
+ const lines = [`${i + 1}. ${r.title || "(untitled)"}`, ` ${r.url}`];
407
+ if (r.snippet) lines.push(` ${r.snippet}`);
408
+ return lines.join("\n");
409
+ })
410
+ .join("\n\n");
411
+ }
412
+
413
+ function formatScrapeText(result) {
414
+ let out = result.markdown;
415
+ if (result.truncated) {
416
+ out += `\n\n[...truncated — showing first ${MAX_SCRAPE_CHARS} of ${result.fullLength} chars...]`;
417
+ }
418
+ return out;
419
+ }
420
+
421
+ // ── CLI entry ─────────────────────────────────────────────────────────────────────────────────
422
+ function printUsage() {
423
+ console.error('usage: firecrawl-cli.mjs search "<query>" [--limit N] [--json]');
424
+ console.error(' firecrawl-cli.mjs scrape "<url>" [--json]');
425
+ console.error(" firecrawl-cli.mjs --self-test");
426
+ }
427
+
428
+ /**
429
+ * Set the exit code and RETURN — never call process.exit() after doing network work.
430
+ *
431
+ * Forcing an exit while the HTTP client still has live handles aborts the process on Windows with
432
+ * `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\\win\\async.c:94` (uv_async_send on
433
+ * an already-closing handle). stdout had already flushed, so callers saw perfect JSON alongside
434
+ * exit code 127: harmless when a human reads stdout, fatal for any pipeline that checks exit
435
+ * status — and it poisoned callers that correctly treat non-zero as failure. Letting the event
436
+ * loop drain on its own exits cleanly and promptly (verified: the same work run to completion
437
+ * without process.exit() returns 0 with no assertion).
438
+ */
439
+ function finish(code) {
440
+ process.exitCode = code;
441
+ }
442
+
443
+ async function exitOnResult(result, { kind, json }) {
444
+ if (!result.ok) {
445
+ if (result.refusedHosted) {
446
+ console.log(result.message);
447
+ return finish(2);
448
+ }
449
+ // Machine-readable, single-line, exact prefix the calling workflow prompts grep for.
450
+ console.log(`FIRECRAWL_UNAVAILABLE: ${result.reason}`);
451
+ return finish(3);
452
+ }
453
+ if (kind === "search") {
454
+ console.log(json ? JSON.stringify({ results: result.results, servedBy: result.servedBy, autostarted: result.autostarted }) : formatSearchList(result.results));
455
+ } else {
456
+ console.log(
457
+ json
458
+ ? JSON.stringify({ url: result.url, title: result.title, markdown: result.markdown, truncated: result.truncated, fullLength: result.fullLength, servedBy: result.servedBy, autostarted: result.autostarted })
459
+ : formatScrapeText(result),
460
+ );
461
+ }
462
+ return finish(0);
463
+ }
464
+
465
+ async function main() {
466
+ const argv = process.argv.slice(2);
467
+ const cmd = argv[0];
468
+ const target = argv[1];
469
+ const json = argv.includes("--json");
470
+ const limitIdx = argv.indexOf("--limit");
471
+ const limit = limitIdx !== -1 ? Number(argv[limitIdx + 1]) || 6 : 6;
472
+
473
+ if (cmd !== "search" && cmd !== "scrape") {
474
+ printUsage();
475
+ process.exit(64);
476
+ return;
477
+ }
478
+ if (!target) {
479
+ console.error(`usage error: missing ${cmd === "search" ? "<query>" : "<url>"} argument`);
480
+ printUsage();
481
+ process.exit(64);
482
+ return;
483
+ }
484
+
485
+ try {
486
+ const result = cmd === "search" ? await runSearch(target, { limit }) : await runScrape(target, {});
487
+ await exitOnResult(result, { kind: cmd, json });
488
+ } catch (e) {
489
+ // Last-resort catch — every internal path above already returns a clean shape, but this
490
+ // guarantees the CLI NEVER surfaces a raw stack trace, per the hard requirement.
491
+ console.log(`FIRECRAWL_UNAVAILABLE: unexpected error — ${e?.message || String(e)}`);
492
+ return finish(3);
493
+ }
494
+ }
495
+
496
+ const isMain = import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? ""));
497
+
498
+ if (isMain && !process.argv.includes("--self-test")) {
499
+ main();
500
+ }
501
+
502
+ // ── self-test (mocked — no real network, no real SSH) — MAIN-entry guarded ──────────────────────
503
+ if (isMain && process.argv.includes("--self-test")) {
504
+ (async () => {
505
+ let ok = true;
506
+ const check = (n, c) => {
507
+ console.log(` ${c ? "✓" : "✗"} ${n}`);
508
+ if (!c) ok = false;
509
+ };
510
+ console.log("== firecrawl-cli.mjs self-test (mock-only, no real network/SSH) ==\n");
511
+ const P = await loadPlatform();
512
+ if (!P) {
513
+ console.log(" (platform-mode self-test needs a HeySpex checkout; local mode: firecrawl-cli.local.test.mjs)");
514
+ return;
515
+ }
516
+ const { HOSTED_FIRECRAWL_BASE_URL } = P.adapter;
517
+
518
+ // A single mock httpFetch that branches on URL shape — reused for BOTH the mode-lease probes
519
+ // (":11434"/":3002" GETs) and the actual Firecrawl POST calls (/search, /scrape).
520
+ const makeMockFetch = ({ ollamaModels = [], firecrawlUp = true, searchResults = [{ title: "T", url: "http://example.com/x", description: "snip" }], scrapeMarkdown = "Hello **world**" } = {}) =>
521
+ async (url) => {
522
+ const u = String(url);
523
+ if (u.includes(":11434")) {
524
+ return { ok: true, status: 200, json: async () => ({ models: ollamaModels }) };
525
+ }
526
+ if (u.endsWith("/search")) {
527
+ return { ok: true, status: 200, json: async () => ({ success: true, data: { web: searchResults } }) };
528
+ }
529
+ if (u.endsWith("/scrape")) {
530
+ return { ok: true, status: 200, json: async () => ({ success: true, data: { markdown: scrapeMarkdown, metadata: { title: "Page Title", statusCode: 200 } } }) };
531
+ }
532
+ // bare-root Firecrawl reachability probe
533
+ if (firecrawlUp) return { ok: true, status: 200, json: async () => ({}) };
534
+ throw new Error("ECONNREFUSED");
535
+ };
536
+ const noSsh = async () => { throw new Error("self-test: SSH must never be called by these cases"); };
537
+
538
+ // Isolate lease cases from fleet failover (Basement / Spec-ops candidates) —
539
+ // each case under test pins a single endpoint via getBaseUrlFn.
540
+ // autostartFn is stubbed OFF for every case below: the real one shells out to
541
+ // firecrawl-local.sh (docker compose), which must never run from a self-test. Cases that
542
+ // expect `unavailable` DO reach the autostart step, so this stub is load-bearing.
543
+ const noFleet = { candidatesFn: () => [], autostartFn: async () => null, remoteStartFn: async () => null, localDetectFn: null };
544
+
545
+ // ── 1. hosted endpoint is refused BEFORE any network call ──
546
+ let networkCalled = false;
547
+ const trackedFetch = async (...a) => { networkCalled = true; throw new Error("must not be called"); };
548
+ const hostedAvail = await ensureAvailable({ getBaseUrlFn: () => HOSTED_FIRECRAWL_BASE_URL, modeOpts: { httpFetch: trackedFetch, runCmd: noSsh }, ...noFleet });
549
+ check("1 hosted endpoint -> refusedHosted:true", hostedAvail.ok === false && hostedAvail.refusedHosted === true);
550
+ check("1b hosted refusal never calls the network", networkCalled === false);
551
+
552
+ // ── 2. self-hosted + Ollama busy -> unavailable, no SSH call (never evicts Ollama) ──
553
+ let sshCalled = false;
554
+ const ollamaBusyFetch = makeMockFetch({ ollamaModels: [{ name: OLLAMA.SMALL }], firecrawlUp: false });
555
+ const busyAvail = await ensureAvailable({
556
+ getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
557
+ modeOpts: { httpFetch: ollamaBusyFetch, runCmd: async () => { sshCalled = true; } },
558
+ ...noFleet,
559
+ });
560
+ check("2 Ollama-busy -> unavailable:true", busyAvail.ok === false && busyAvail.unavailable === true);
561
+ check("2b reason names the Ollama lease", /ollama/i.test(busyAvail.reason));
562
+ check("2c never calls SSH (no eviction)", sshCalled === false);
563
+
564
+ // ── 3. self-hosted + box offline -> unavailable ──
565
+ const offlineFetch = async () => { throw new Error("ECONNREFUSED"); };
566
+ const offlineAvail = await ensureAvailable({ getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: offlineFetch, runCmd: noSsh }, ...noFleet });
567
+ check("3 offline box -> unavailable:true", offlineAvail.ok === false && offlineAvail.unavailable === true);
568
+
569
+ // ── 4. self-hosted + box idle -> lease requested and granted (compose up mocked, no real SSH) ──
570
+ sshCalled = false;
571
+ const idleFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: false });
572
+ const idleAvail = await ensureAvailable({
573
+ getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
574
+ modeOpts: { httpFetch: idleFetch, runCmd: async () => { sshCalled = true; } },
575
+ ...noFleet,
576
+ });
577
+ check("4 idle box -> lease granted", idleAvail.ok === true);
578
+ check("4b idle grant DOES start the stack (mocked SSH call made)", sshCalled === true);
579
+
580
+ // ── 5. self-hosted + Firecrawl already up -> granted directly, no SSH ──
581
+ sshCalled = false;
582
+ const upFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
583
+ const upAvail = await ensureAvailable({
584
+ getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
585
+ modeOpts: { httpFetch: upFetch, runCmd: async () => { sshCalled = true; } },
586
+ ...noFleet,
587
+ });
588
+ check("5 already-up -> lease granted with no SSH call", upAvail.ok === true && sshCalled === false);
589
+
590
+ // ── 5d. hosted primary + Spec-ops/Basement candidate up -> failover grant (duplicate option) ──
591
+ const fleetUpFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
592
+ const failoverAvail = await ensureAvailable({
593
+ getBaseUrlFn: () => HOSTED_FIRECRAWL_BASE_URL,
594
+ candidatesFn: () => ["http://192.168.1.98:3002/v2"],
595
+ modeOpts: { httpFetch: fleetUpFetch, runCmd: noSsh },
596
+ });
597
+ check("5d hosted primary fails over to Spec-ops candidate", failoverAvail.ok === true && failoverAvail.baseUrl === "http://192.168.1.98:3002/v2");
598
+
599
+ // ── 6. runSearch end-to-end (mocked): returns normalized {title,url,snippet}[] ──
600
+ const searchFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
601
+ const searchResult = await runSearch("test query", {
602
+ httpFetch: searchFetch,
603
+ availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: searchFetch, runCmd: noSsh }, ...noFleet },
604
+ });
605
+ check("6 runSearch resolves ok:true with results", searchResult.ok === true && searchResult.results.length === 1 && searchResult.results[0].url === "http://example.com/x");
606
+ check("6b formatSearchList renders a readable list", formatSearchList(searchResult.results).includes("http://example.com/x"));
607
+
608
+ // ── 7. runSearch when unavailable (Ollama busy) -> clean unavailable shape, not a throw ──
609
+ const busySearchResult = await runSearch("q", {
610
+ availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: ollamaBusyFetch, runCmd: noSsh }, ...noFleet },
611
+ });
612
+ check("7 runSearch under Ollama lease -> unavailable:true (not a throw)", busySearchResult.ok === false && busySearchResult.unavailable === true);
613
+
614
+ // ── 8. runScrape end-to-end (mocked): returns markdown + title ──
615
+ const scrapeFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true, scrapeMarkdown: "Hello **world**" });
616
+ const scrapeResult = await runScrape("http://example.com/x", {
617
+ httpFetch: scrapeFetch,
618
+ availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: scrapeFetch, runCmd: noSsh }, ...noFleet },
619
+ });
620
+ check("8 runScrape resolves ok:true", scrapeResult.ok === true && scrapeResult.title === "Page Title");
621
+ check("8b scrape markdown content present", /Hello \*\*world\*\*/.test(scrapeResult.markdown));
622
+ check("8c not truncated (short body)", scrapeResult.truncated === false);
623
+
624
+ // ── 9. runScrape truncates at MAX_SCRAPE_CHARS and notes it ──
625
+ const longMarkdown = "x".repeat(MAX_SCRAPE_CHARS + 500);
626
+ const longFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true, scrapeMarkdown: longMarkdown });
627
+ const longResult = await runScrape("http://example.com/long", {
628
+ httpFetch: longFetch,
629
+ availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: longFetch, runCmd: noSsh }, ...noFleet },
630
+ });
631
+ check("9 long page truncated to MAX_SCRAPE_CHARS", longResult.ok === true && longResult.markdown.length === MAX_SCRAPE_CHARS && longResult.truncated === true);
632
+ check("9b formatScrapeText appends a truncation note", formatScrapeText(longResult).includes("truncated"));
633
+
634
+ // ── 10. network/parse failure inside runSearch/runScrape never throws ──
635
+ const throwingFetch = async (url) => {
636
+ if (String(url).includes(":11434") || String(url).endsWith("/") === false && !String(url).endsWith("/search") && !String(url).endsWith("/scrape")) {
637
+ return { ok: true, status: 200, json: async () => ({ models: [] }) };
638
+ }
639
+ throw new Error("ECONNRESET");
640
+ };
641
+ const errSearch = await runSearch("q", {
642
+ httpFetch: throwingFetch,
643
+ availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: makeMockFetch({ ollamaModels: [], firecrawlUp: true }), runCmd: noSsh }, ...noFleet },
644
+ });
645
+ check("10 network failure inside search -> unavailable:true, not a throw", errSearch.ok === false && errSearch.unavailable === true);
646
+
647
+ // ── 11. on-demand local autostart: fires ONLY after every candidate failed, and is fail-safe ──
648
+ // The workstation stack is normally stopped ([[workstation-firecrawl-is-the-fleet-down]]), so a
649
+ // fleet-down run must be able to start it and retry — without ever weakening the refusal path.
650
+ const localUpFetch = async (url) => {
651
+ if (String(url).includes("127.0.0.1")) return { ok: true, status: 200, json: async () => ({}) };
652
+ throw new Error("ECONNREFUSED");
653
+ };
654
+ const autoAvail = await ensureAvailable({
655
+ getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
656
+ modeOpts: { httpFetch: localUpFetch, runCmd: noSsh },
657
+ candidatesFn: () => [],
658
+ localDetectFn: null,
659
+ autostartFn: async () => "http://127.0.0.1:3002/v2",
660
+ remoteStartFn: async () => null,
661
+ });
662
+ check("11 all candidates down -> autostart rescues the run", autoAvail.ok === true && autoAvail.baseUrl === "http://127.0.0.1:3002/v2");
663
+ check("11b rescued result is flagged autostarted:true", autoAvail.autostarted === true);
664
+
665
+ // a failed start must degrade to the ORIGINAL unavailable, never to a crash
666
+ const autoFail = await ensureAvailable({
667
+ getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
668
+ modeOpts: { httpFetch: offlineFetch, runCmd: noSsh },
669
+ candidatesFn: () => [],
670
+ localDetectFn: null,
671
+ autostartFn: async () => null,
672
+ remoteStartFn: async () => null,
673
+ });
674
+ check("11c failed autostart -> still unavailable:true", autoFail.ok === false && autoFail.unavailable === true);
675
+
676
+ const autoThrow = await ensureAvailable({
677
+ getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
678
+ modeOpts: { httpFetch: offlineFetch, runCmd: noSsh },
679
+ candidatesFn: () => [],
680
+ localDetectFn: null,
681
+ autostartFn: async () => { throw new Error("docker exploded"); },
682
+ remoteStartFn: async () => null,
683
+ });
684
+ check("11d throwing autostart -> unavailable:true, not a throw", autoThrow.ok === false && autoThrow.unavailable === true);
685
+
686
+ // never start anything when a candidate already answered
687
+ let autostartCalled = false;
688
+ const noStartNeeded = await ensureAvailable({
689
+ getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
690
+ modeOpts: { httpFetch: upFetch, runCmd: async () => {} },
691
+ candidatesFn: () => [],
692
+ localDetectFn: null,
693
+ autostartFn: async () => { autostartCalled = true; return null; },
694
+ remoteStartFn: async () => null,
695
+ });
696
+ check("11e a healthy candidate never triggers autostart", noStartNeeded.ok === true && autostartCalled === false);
697
+
698
+ // the hosted refusal must still short-circuit BEFORE any autostart attempt
699
+ let hostedAutostartCalled = false;
700
+ const hostedStill = await ensureAvailable({
701
+ getBaseUrlFn: () => HOSTED_FIRECRAWL_BASE_URL,
702
+ modeOpts: { httpFetch: trackedFetch, runCmd: noSsh },
703
+ candidatesFn: () => [],
704
+ localDetectFn: null,
705
+ autostartFn: async () => { hostedAutostartCalled = true; return "http://127.0.0.1:3002/v2"; },
706
+ remoteStartFn: async () => null,
707
+ });
708
+ check("11f hosted refusal still short-circuits, no autostart", hostedStill.refusedHosted === true && hostedAutostartCalled === false);
709
+
710
+ // ── 11g-11i. remote-start (Spec-ops SSH script) fires as the THIRD fallback, after local
711
+ // autostart also fails — and is itself fail-safe (never throws, degrades to unavailable). ──
712
+ let remoteStartCalled = false;
713
+ const remoteRescue = await ensureAvailable({
714
+ getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
715
+ modeOpts: { httpFetch: localUpFetch, runCmd: noSsh },
716
+ candidatesFn: () => [],
717
+ localDetectFn: null,
718
+ autostartFn: async () => null,
719
+ remoteStartFn: async () => { remoteStartCalled = true; return "http://127.0.0.1:3002/v2"; },
720
+ });
721
+ check("11g local autostart failing falls through to remoteStartFn", remoteStartCalled === true);
722
+ check("11h remote-started result is flagged autostarted:true", remoteRescue.ok === true && remoteRescue.autostarted === true);
723
+
724
+ const remoteThrow = await ensureAvailable({
725
+ getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
726
+ modeOpts: { httpFetch: offlineFetch, runCmd: noSsh },
727
+ candidatesFn: () => [],
728
+ localDetectFn: null,
729
+ autostartFn: async () => null,
730
+ remoteStartFn: async () => { throw new Error("ssh exploded"); },
731
+ });
732
+ check("11i throwing remoteStartFn -> unavailable:true, not a throw", remoteThrow.ok === false && remoteThrow.unavailable === true);
733
+
734
+ // ── 11j-11l. remoteStartCandidates(): excludes THIS machine, dedupes shared-stack GPUs,
735
+ // skips machines with no declared script. Reads the real routing-table.json by path (mirrors
736
+ // localOnDemandFirecrawl's shape, no table-injection param) — exercises the LIVE table's
737
+ // actual declarations (specops-gpu0/gpu1 -> firecrawl-specops.sh, basement-geek -> none). ──
738
+ const liveRemote = remoteStartCandidates({ HEYSPEX_FLEET_MACHINE_KEY: "coffee-geek" });
739
+ check("11j live routing-table declares Spec-ops' remote-start script, deduped to one entry", liveRemote.filter((c) => c.machineKey.startsWith("specops")).length === 1);
740
+ check("11k live routing-table excludes basement-geek (no remoteStart declared)", !liveRemote.some((c) => c.machineKey === "basement-geek"));
741
+ const selfExcluded = remoteStartCandidates({ HEYSPEX_FLEET_MACHINE_KEY: "specops-gpu0" });
742
+ check("11l remoteStartCandidates excludes the machine THIS process is on", !selfExcluded.some((c) => c.machineKey === "specops-gpu0"));
743
+
744
+ // ── 12. servedBy reports the candidate that ACTUALLY answered ──
745
+ const servedFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
746
+ const servedResult = await runSearch("q", {
747
+ httpFetch: servedFetch,
748
+ availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: servedFetch, runCmd: noSsh }, ...noFleet },
749
+ });
750
+ check("12 runSearch reports servedBy", servedResult.ok === true && servedResult.servedBy === "http://192.168.1.252:3002/v2");
751
+
752
+ console.log(`\n${ok ? "✓ self-test PASS" : "✗ self-test FAIL"}`);
753
+ process.exit(ok ? 0 : 1);
754
+ })();
755
+ }