frizz 0.5.0 → 0.6.0

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 (102) hide show
  1. package/README.md +3 -3
  2. package/dist/claude-agent-broker.js +50 -7
  3. package/dist/dev-child.js +8820 -1420
  4. package/dist/frizz.js +7589 -240
  5. package/package.json +2 -1
  6. package/runtime/cc-worker/DECISIONS.md +2 -2
  7. package/runtime/cc-worker/bin/browser-mcp-tools.json +1192 -0
  8. package/runtime/cc-worker/bin/browser-mcp.mjs +730 -0
  9. package/runtime/cc-worker/bin/frizz-mcp.mjs +16 -5
  10. package/runtime/cc-worker/hooks/deny-ask.mjs +3 -3
  11. package/runtime/cc-worker/hooks/deny-plan.mjs +5 -4
  12. package/runtime/cc-worker/hooks/perm-policy.mjs +1 -1
  13. package/web-dist/assets/{TerminalPane-WcpgmCA-.js → TerminalPane-GfnCbZQ8.js} +1 -1
  14. package/web-dist/assets/{abnfDiagram-VRR7QNED-Caecklbz.js → abnfDiagram-VRR7QNED-By12QfQW.js} +1 -1
  15. package/web-dist/assets/architecture-TIHT7OUA-XOYod28l.js +1 -0
  16. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-BEk0fg8C.js → architectureDiagram-ZJ3FMSHR-CcXU45QO.js} +1 -1
  17. package/web-dist/assets/{blockDiagram-677ZJIJ3-B4yq3xiy.js → blockDiagram-677ZJIJ3-CmzyO2OF.js} +1 -1
  18. package/web-dist/assets/{c4Diagram-LMCZKHZV-DaA5NBkz.js → c4Diagram-LMCZKHZV-DFGSW_ZS.js} +1 -1
  19. package/web-dist/assets/channel-Bqn3pFKp.js +1 -0
  20. package/web-dist/assets/{chunk-32BRIVSS-2XsSs8F_.js → chunk-32BRIVSS-whgaFxPY.js} +1 -1
  21. package/web-dist/assets/{chunk-52WLFC77-DWJfECP3.js → chunk-52WLFC77-DCT0MLgl.js} +1 -1
  22. package/web-dist/assets/{chunk-C7G6YPKG-Bz07eaGt.js → chunk-C7G6YPKG-Br0OaxIX.js} +1 -1
  23. package/web-dist/assets/{chunk-EX3LRPZG-D78kZEMM.js → chunk-EX3LRPZG-CUnQkdEF.js} +1 -1
  24. package/web-dist/assets/{chunk-FWX5IMBZ-CeCa17CW.js → chunk-FWX5IMBZ-D1NqKRFr.js} +2 -2
  25. package/web-dist/assets/{chunk-HOUHSVGY-9H4GFuBa.js → chunk-HOUHSVGY-7ZL78OrO.js} +1 -1
  26. package/web-dist/assets/{chunk-ICXQ74PX--Aa17r4X.js → chunk-ICXQ74PX-QSgBAuYw.js} +1 -1
  27. package/web-dist/assets/{chunk-MOJQB5TN-C3vXhsPV.js → chunk-MOJQB5TN-DOI-0kaG.js} +1 -1
  28. package/web-dist/assets/{chunk-OGEWGWER-G4kZRBkX.js → chunk-OGEWGWER-CZ3ED-QB.js} +1 -1
  29. package/web-dist/assets/{chunk-PUDLZKDR-DWd5kvyM.js → chunk-PUDLZKDR-BQJElUFX.js} +1 -1
  30. package/web-dist/assets/{chunk-Q4XR5HBZ-u55p39Up.js → chunk-Q4XR5HBZ-5B6Qs7rQ.js} +1 -1
  31. package/web-dist/assets/{chunk-V7JOEXUC-BU4YBmV4.js → chunk-V7JOEXUC-DkMaAMFK.js} +1 -1
  32. package/web-dist/assets/{chunk-VAUOI2AC-VXcmT20q.js → chunk-VAUOI2AC-Bvq-8GYO.js} +1 -1
  33. package/web-dist/assets/{chunk-VR4S4FIN-CSjEXLOC.js → chunk-VR4S4FIN-DRVTU-HZ.js} +1 -1
  34. package/web-dist/assets/{chunk-WYO6CB5R-4WGk2dpL.js → chunk-WYO6CB5R-CRxI5d1E.js} +1 -1
  35. package/web-dist/assets/{chunk-ZGVPDNZ5-D5Xl8ZV6.js → chunk-ZGVPDNZ5-D_3ow6xU.js} +1 -1
  36. package/web-dist/assets/classDiagram-OUVF2IWQ-DEMCehy_.js +1 -0
  37. package/web-dist/assets/classDiagram-v2-EOCWNBFH-DEMCehy_.js +1 -0
  38. package/web-dist/assets/{cynefin-VYW2F7L2-O6wB49wK.js → cynefin-VYW2F7L2-Bexf41pD.js} +1 -1
  39. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-pd6ZogNC.js → cynefinDiagram-TSTJHNR4-9gbbl1EH.js} +1 -1
  40. package/web-dist/assets/{dagre-VKFMJZFB-D8W1S6fw.js → dagre-VKFMJZFB-B04shbL0.js} +1 -1
  41. package/web-dist/assets/{diagram-FQU43EPY-Bm1NOkHd.js → diagram-FQU43EPY-CGXlhEsB.js} +1 -1
  42. package/web-dist/assets/{diagram-G47NLZAW-CjwnXTIc.js → diagram-G47NLZAW-DmPCeTOa.js} +1 -1
  43. package/web-dist/assets/{diagram-NH7WQ7WH-DH1jZ_mu.js → diagram-NH7WQ7WH-D8Hthvi7.js} +1 -1
  44. package/web-dist/assets/{diagram-OA4YK3LP-B-s969Q_.js → diagram-OA4YK3LP-DbLR6tfL.js} +1 -1
  45. package/web-dist/assets/{diagram-WEI45ONY-BU3lv6cL.js → diagram-WEI45ONY-B6YWev_0.js} +1 -1
  46. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-FsAeyqcC.js → ebnfDiagram-CCIWWBDH-8G1uf683.js} +1 -1
  47. package/web-dist/assets/{erDiagram-Q63AITRT-BRvg5Y9d.js → erDiagram-Q63AITRT-U_VRdQHz.js} +1 -1
  48. package/web-dist/assets/eventmodeling-45OFAUF4-DIpO2jQx.js +1 -0
  49. package/web-dist/assets/flowDiagram-23GEKE2U-aERDdceE.js +1 -0
  50. package/web-dist/assets/{ganttDiagram-NO4QXBWP-eATKNh9U.js → ganttDiagram-NO4QXBWP-Cnsi9uVf.js} +1 -1
  51. package/web-dist/assets/{gitGraph-TEB2WS4Q-CNYqsua8.js → gitGraph-TEB2WS4Q-DYf7ka-G.js} +1 -1
  52. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-BUxgluf8.js → gitGraphDiagram-IHSO6WYX-Bvmr7dEH.js} +1 -1
  53. package/web-dist/assets/index-Czv3R-v0.js +486 -0
  54. package/web-dist/assets/index-D8iyWUZ0.css +1 -0
  55. package/web-dist/assets/{info-DKCQHKI2-HsVLwo1c.js → info-DKCQHKI2-CdP9Q9r0.js} +1 -1
  56. package/web-dist/assets/{infoDiagram-FWYZ7A6U-D7U65xDp.js → infoDiagram-FWYZ7A6U-RBcOH3Pi.js} +1 -1
  57. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-BsfIYXjX.js → ishikawaDiagram-FXEZZL3T-zS0gy3fS.js} +1 -1
  58. package/web-dist/assets/{journeyDiagram-5HDEW3XC-CpkREKol.js → journeyDiagram-5HDEW3XC-cOciJlFY.js} +1 -1
  59. package/web-dist/assets/{kanban-definition-HUTT4EX6-2M3KW6Gw.js → kanban-definition-HUTT4EX6-B-v0XClo.js} +1 -1
  60. package/web-dist/assets/{line-Cs7zWs24.js → line-z44YXdci.js} +1 -1
  61. package/web-dist/assets/{mermaid-parser.core-Dw-IECV5.js → mermaid-parser.core-0Bg1x4p0.js} +3 -3
  62. package/web-dist/assets/{mermaid.core-C1lrddjT.js → mermaid.core-ByFqgcIE.js} +3 -3
  63. package/web-dist/assets/{mindmap-definition-LN4V7U3C-gVS5On1g.js → mindmap-definition-LN4V7U3C-DiGaAQFh.js} +1 -1
  64. package/web-dist/assets/{packet-7NZHBO7P-Tti1-UWA.js → packet-7NZHBO7P-Bv8ILo_S.js} +1 -1
  65. package/web-dist/assets/{pegDiagram-2B236MQR-DE5in93G.js → pegDiagram-2B236MQR-CtOUW7n0.js} +1 -1
  66. package/web-dist/assets/{pie-RZYD4A2V-DfLNkvHl.js → pie-RZYD4A2V-Dz4Bm-Vx.js} +1 -1
  67. package/web-dist/assets/{pieDiagram-ENE6RG2P-DcqSqJqb.js → pieDiagram-ENE6RG2P-VMY_CK3i.js} +1 -1
  68. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-DR-nRPgN.js → quadrantDiagram-ABIIQ3AL-V7HZxM7j.js} +1 -1
  69. package/web-dist/assets/{radar-I7S5WNFK-DGLDBfPd.js → radar-I7S5WNFK-CWk6RruH.js} +1 -1
  70. package/web-dist/assets/{railroad-3IZDKUUU-BaiGEHlV.js → railroad-3IZDKUUU-m_8st_Ez.js} +1 -1
  71. package/web-dist/assets/railroad-abnf-AHOZXSZD-DIaRscxr.js +1 -0
  72. package/web-dist/assets/railroad-ebnf-EBAXGLYW-CeHpbK-W.js +1 -0
  73. package/web-dist/assets/railroad-peg-LSFZ7HO6-CbhVIbOj.js +1 -0
  74. package/web-dist/assets/{railroadDiagram-RFXS5EU6-82aEev5_.js → railroadDiagram-RFXS5EU6-Dz80V-7-.js} +1 -1
  75. package/web-dist/assets/{requirementDiagram-TGXJPOKE-Bs8opBRj.js → requirementDiagram-TGXJPOKE-DrDDgTwm.js} +1 -1
  76. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-gOb3FIr8.js → sankeyDiagram-HTMAVEWB-D4SeC-Bi.js} +1 -1
  77. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-6DvjtRHR.js → sequenceDiagram-DBY2YBRQ-CA-ZO_qp.js} +1 -1
  78. package/web-dist/assets/{stateDiagram-2N3HPSRC-Cg2LWdUf.js → stateDiagram-2N3HPSRC-C_ZYvV2s.js} +1 -1
  79. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-BLevFa3H.js +1 -0
  80. package/web-dist/assets/{swimlanes-5IMT3BWC-BDpPdYEl.js → swimlanes-5IMT3BWC-DGMDseYx.js} +1 -1
  81. package/web-dist/assets/swimlanesDiagram-G3AALYLV-00mQRq57.js +8 -0
  82. package/web-dist/assets/{timeline-definition-FHXFAJF6-BSjfeutv.js → timeline-definition-FHXFAJF6-CEiMp3_j.js} +1 -1
  83. package/web-dist/assets/{treeView-QDETBFTQ-DwgPy6V3.js → treeView-QDETBFTQ-BSspY0US.js} +1 -1
  84. package/web-dist/assets/{treemap-6X3UGDF4-7eiifTKW.js → treemap-6X3UGDF4-DXNQxmQ7.js} +1 -1
  85. package/web-dist/assets/{vennDiagram-L72KCM5P-9u4AhcnV.js → vennDiagram-L72KCM5P-C-khqkdm.js} +1 -1
  86. package/web-dist/assets/{wardley-OPB4EBWU-DqNb5x9U.js → wardley-OPB4EBWU-B9QCQ_Tm.js} +1 -1
  87. package/web-dist/assets/{wardleyDiagram-EHGQE667-DlJ7h3vg.js → wardleyDiagram-EHGQE667-vhVpAUzh.js} +1 -1
  88. package/web-dist/assets/{xychartDiagram-FW5EYKEG-B_9ukg2S.js → xychartDiagram-FW5EYKEG-B8ev9zqs.js} +1 -1
  89. package/web-dist/index.html +2 -2
  90. package/web-dist/assets/architecture-TIHT7OUA-BTy2nvYL.js +0 -1
  91. package/web-dist/assets/channel-WghlT4VS.js +0 -1
  92. package/web-dist/assets/classDiagram-OUVF2IWQ-D21-Dy4r.js +0 -1
  93. package/web-dist/assets/classDiagram-v2-EOCWNBFH-D21-Dy4r.js +0 -1
  94. package/web-dist/assets/eventmodeling-45OFAUF4-RhX2WA7g.js +0 -1
  95. package/web-dist/assets/flowDiagram-23GEKE2U-C7N14tdV.js +0 -1
  96. package/web-dist/assets/index-BFuReKr0.js +0 -368
  97. package/web-dist/assets/index-BH9U3Zlg.css +0 -1
  98. package/web-dist/assets/railroad-abnf-AHOZXSZD-BCeoIOWA.js +0 -1
  99. package/web-dist/assets/railroad-ebnf-EBAXGLYW-lZpXJLD8.js +0 -1
  100. package/web-dist/assets/railroad-peg-LSFZ7HO6-CaUBrHTL.js +0 -1
  101. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-Digf9CWZ.js +0 -1
  102. package/web-dist/assets/swimlanesDiagram-G3AALYLV-DEZRDWHA.js +0 -8
@@ -0,0 +1,730 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * browser-mcp — frizz's LAZY PROXY in front of the upstream `chrome-devtools-mcp` server.
5
+ *
6
+ * Every frizz worker mounts a browser MCP server, because the runtime release gate needs a browser on
7
+ * any machine and neither backend can assume the operator configured one. Most workers never open a
8
+ * page. Measured on the maintainer's machine 2026-08-19, mounting the real thing eagerly through
9
+ * `npx -y chrome-devtools-mcp@latest` cost **6.07 GB across 78 processes** — 32% of frizz's entire
10
+ * footprint — split in two:
11
+ *
12
+ * - 39 × 89 MB the real server, started at session start whether or not a browser is ever opened;
13
+ * - 39 × 70 MB the `npm exec` shim `npx` leaves behind after it execs the real one. Pure waste:
14
+ * no behaviour is attached to it at all.
15
+ *
16
+ * This script replaces BOTH. It is a dependency-free stdio MCP server that:
17
+ *
18
+ * 1. answers `initialize`, `tools/list`, `ping` and `logging/setLevel` ITSELF, from a tool-schema
19
+ * snapshot on disk — so a worker's tool registry is complete at session start with nothing
20
+ * spawned but this ~17 MB node process;
21
+ * 2. spawns the real server LAZILY, on the first `tools/call`, as `node <resolved bin>` — no npx,
22
+ * no npm shim left running;
23
+ * 3. proxies everything transparently from then on, in BOTH directions.
24
+ *
25
+ * ── WHY THE CLIENT'S OWN `initialize` PARAMS ARE REPLAYED VERBATIM ────────────────────────────────
26
+ * A proxy that introduces itself as the client is not transparent, and the failure is silent rather
27
+ * than loud. chrome-devtools-mcp reads the client's negotiated capabilities: with no `roots`
28
+ * capability it prints "File-writing tools will be restricted to the OS temp directory" on stderr and
29
+ * quietly confines `take_screenshot` there. Claude Code DOES negotiate roots, so a proxy that sent its
30
+ * own handshake would take every worker's screenshots away without failing anything. So the client's
31
+ * `initialize` params are stored and handed to the child unchanged, and the child's own requests back
32
+ * up the wire (`roots/list`, sampling, elicitation) are forwarded to the real client and their
33
+ * responses forwarded down. The only message this process ever authors on the child's stdin is its own
34
+ * `initialize`, under a reserved string id nothing else uses.
35
+ *
36
+ * ── THE TOOL-SCHEMA SNAPSHOT ──────────────────────────────────────────────────────────────────────
37
+ * The whole saving depends on answering `tools/list` with the real server NOT running, and the schemas
38
+ * only exist inside the real server. So they are cached on disk, keyed by the package version:
39
+ *
40
+ * 1. `<cache>/tools-<version>.json` — harvested at runtime, written once per version per machine;
41
+ * 2. `browser-mcp-tools.json` beside — the snapshot committed to the repo, regenerated by
42
+ * this file `nub scripts/harvest-browser-mcp-tools.mjs` (which is this
43
+ * file under `--frizz-harvest`). It is what makes a machine
44
+ * that has NEVER run frizz answer `tools/list` instantly:
45
+ * without it the first worker would have to install and boot
46
+ * the real server inside the client's MCP-startup window,
47
+ * and a client that gives up there leaves the worker with no
48
+ * browser tools for its whole session.
49
+ * 3. a bounded live harvest — install + boot + `tools/list` + write (1). Reached only
50
+ * when the pinned version moved and (2) was not regenerated.
51
+ * 4. a version-MISMATCHED (2) — stale schemas beat no schemas.
52
+ *
53
+ * Whatever was served, the truth is reconciled the moment the child does start: its real `tools/list`
54
+ * is compared with what we served, and on a difference (1) is rewritten and
55
+ * `notifications/tools/list_changed` is sent so the client refetches.
56
+ *
57
+ * ── WHERE THE PACKAGE COMES FROM ──────────────────────────────────────────────────────────────────
58
+ * `chrome-devtools-mcp` is NOT a dependency of `frizz`: it unpacks to 13 MB (it vendors puppeteer-core
59
+ * and has zero transitive deps), which is dead weight in the published tarball for every user who never
60
+ * dispatches browser work. Instead it is installed ONCE per machine per version, on first use, into
61
+ * `~/.frizz/browser-mcp/pkg/<version>/`, and exec'd from there forever after. That is strictly less
62
+ * network than the `npx -y …@latest` it replaces (which re-resolved `latest` on every single spawn).
63
+ * An install already present anywhere resolvable from this file wins first, so a layout that DOES vend
64
+ * the package needs no download at all.
65
+ *
66
+ * Concurrency is handled without a lock: each process installs into its own `mkdtemp` and then
67
+ * `rename`s it into place, which is atomic. A loser's rename fails on the non-empty target, it throws
68
+ * its copy away, and both processes end up using the winner's.
69
+ *
70
+ * ── PROTOCOL ──────────────────────────────────────────────────────────────────────────────────────
71
+ * MCP over stdio = newline-delimited JSON-RPC 2.0, hand-rolled for the same reasons frizz-mcp.mjs
72
+ * hand-rolls it: the surface is tiny and this ships as one loose .mjs inside the worker plugin, with no
73
+ * build, bundle or resolution step. stdout carries protocol frames and NOTHING else; every diagnostic
74
+ * goes to stderr, where the client logs it.
75
+ */
76
+ import { spawn } from "node:child_process"
77
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
78
+ import { createRequire } from "node:module"
79
+ import { homedir } from "node:os"
80
+ import { dirname, join } from "node:path"
81
+ import { fileURLToPath } from "node:url"
82
+
83
+ const HERE = dirname(fileURLToPath(import.meta.url))
84
+ const SNAPSHOT_PATH = join(HERE, "browser-mcp-tools.json")
85
+ const PROTOCOL_FALLBACK = "2025-06-18"
86
+ /** The id this process uses for the ONE request it authors on the child's stdin. */
87
+ const INIT_ID = "frizz-browser-mcp/initialize"
88
+ const LOG_ID = "frizz-browser-mcp/logging"
89
+ /** How long `tools/list` may block on a live harvest before falling back to stale schemas. */
90
+ const HARVEST_BUDGET_MS = 25_000
91
+ /** How long `npm install` may run. Generous: it is paid once per machine per version. */
92
+ const INSTALL_BUDGET_MS = 300_000
93
+ /** How long the real server gets to answer `initialize` before the call it is holding up gives up. */
94
+ const HANDSHAKE_BUDGET_MS = 60_000
95
+
96
+ // ---- argv ----------------------------------------------------------------------------------------
97
+ // Everything after the frizz-only `--frizz-harvest` mode flag is the upstream server's own argv,
98
+ // forwarded byte-for-byte. `--headless` and `--isolated` ride in there and are NOT this file's to
99
+ // second-guess: see the CHROME_DEVTOOLS_MCP comment in packages/server/src/backend/types.ts for why
100
+ // both are required.
101
+ const rawArgs = process.argv.slice(2)
102
+ const HARVEST = rawArgs.includes("--frizz-harvest")
103
+ const BROWSER_ARGS = rawArgs.filter((a) => a !== "--frizz-harvest")
104
+
105
+ // ---- what package, which version ----------------------------------------------------------------
106
+ // The server stamps FRIZZ_BROWSER_MCP_PACKAGE from the ONE canonical spec (backend/types.ts), so the
107
+ // pin lives there and not in two places. The snapshot is the fallback so this file stays runnable by
108
+ // hand, and `@latest` is the last resort so it degrades to the old behaviour rather than to nothing.
109
+ const snapshot = readJson(SNAPSHOT_PATH)
110
+ const spec = parseSpec(
111
+ process.env.FRIZZ_BROWSER_MCP_PACKAGE ||
112
+ (snapshot?.package && snapshot?.version ? `${snapshot.package}@${snapshot.version}` : "chrome-devtools-mcp@latest"),
113
+ )
114
+ /** Test/ops hook: an explicit install root, so a sandbox never writes to the real `~/.frizz`. */
115
+ const CACHE_DIR = process.env.FRIZZ_BROWSER_MCP_HOME || join(homedir(), ".frizz", "browser-mcp")
116
+
117
+ /** @param {string} value @returns {{name: string, version: string}} */
118
+ function parseSpec(value) {
119
+ const at = value.lastIndexOf("@")
120
+ if (at <= 0) return { name: value, version: "latest" }
121
+ return { name: value.slice(0, at), version: value.slice(at + 1) }
122
+ }
123
+
124
+ /** @param {string} path @returns {any} */
125
+ function readJson(path) {
126
+ try {
127
+ return JSON.parse(readFileSync(path, "utf8"))
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Diagnostics. stderr is where an MCP client is supposed to collect them, but a worker's client keeps
135
+ * that in a per-session log nobody reads, and this process does its most interesting work (installing,
136
+ * harvesting, starting the real server) at moments nobody is watching. `FRIZZ_BROWSER_MCP_TRACE=<path>`
137
+ * appends the same lines to a file so a live worker can be traced without a debugger. Off by default.
138
+ *
139
+ * @param {string} message
140
+ */
141
+ function note(message) {
142
+ const line = `[frizz browser-mcp ${process.pid}] ${message}\n`
143
+ try {
144
+ process.stderr.write(line)
145
+ } catch {
146
+ /* a closed stderr is not worth dying over */
147
+ }
148
+ const trace = process.env.FRIZZ_BROWSER_MCP_TRACE
149
+ if (trace) {
150
+ try {
151
+ appendFileSync(trace, `${new Date().toISOString()} ${line}`)
152
+ } catch {
153
+ /* a trace that cannot be written is not worth failing a tool call over */
154
+ }
155
+ }
156
+ }
157
+
158
+ // ---- stdout framing ------------------------------------------------------------------------------
159
+ /** @param {unknown} obj */
160
+ function send(obj) {
161
+ try {
162
+ process.stdout.write(JSON.stringify(obj) + "\n")
163
+ } catch {
164
+ /* the client hung up; the exit handlers will tear the child down */
165
+ }
166
+ }
167
+ /** @param {string|number} id @param {unknown} result */
168
+ function reply(id, result) {
169
+ send({ jsonrpc: "2.0", id, result })
170
+ }
171
+ /** @param {string|number} id @param {number} code @param {string} message */
172
+ function replyError(id, code, message) {
173
+ send({ jsonrpc: "2.0", id, error: { code, message } })
174
+ }
175
+
176
+ // ---- the tool-schema cache -----------------------------------------------------------------------
177
+ const cacheFile = () => join(CACHE_DIR, `tools-${spec.version}.json`)
178
+
179
+ /** The registry this process serves `initialize` + `tools/list` from. Resolved once, lazily. */
180
+ let registry = null
181
+
182
+ /**
183
+ * The registry WITHOUT paying for a harvest — the version cache, else the committed snapshot at any
184
+ * version. `initialize` uses only this: a client that does not get its handshake promptly declares the
185
+ * server dead, and the worker then has no browser tools AT ALL for the whole session, which is the
186
+ * one outcome worse than an eagerly-spawned server.
187
+ */
188
+ function peekRegistry() {
189
+ if (registry) return registry
190
+ const cached = readJson(cacheFile())
191
+ if (cached?.tools?.length) return (registry = cached)
192
+ if (snapshot?.tools?.length && snapshot.version === spec.version) return (registry = snapshot)
193
+ return undefined
194
+ }
195
+
196
+ /** @returns {Promise<{tools: any[], capabilities?: any, serverInfo?: any, version?: string}>} */
197
+ async function loadRegistry() {
198
+ const peeked = peekRegistry()
199
+ if (peeked) return peeked
200
+ // Nothing pinned to this exact version: pay the harvest, bounded, so a client that is waiting on
201
+ // `tools/list` gets an answer either way.
202
+ try {
203
+ const harvested = await withTimeout(harvest(), HARVEST_BUDGET_MS, "tool-schema harvest")
204
+ writeCache(harvested)
205
+ return (registry = harvested)
206
+ } catch (err) {
207
+ note(`could not harvest tool schemas (${errText(err)})`)
208
+ }
209
+ if (snapshot?.tools?.length) {
210
+ // Stale schemas beat none: the tool names and shapes move rarely, and a worker with a slightly
211
+ // out-of-date schema still gets a browser. A worker with an empty registry does not.
212
+ note(`serving the committed snapshot for ${snapshot.package}@${snapshot.version} against a pinned ${spec.version}`)
213
+ return (registry = snapshot)
214
+ }
215
+ return (registry = { tools: [] })
216
+ }
217
+
218
+ /** @param {any} value */
219
+ function writeCache(value) {
220
+ try {
221
+ mkdirSync(CACHE_DIR, { recursive: true })
222
+ const tmp = `${cacheFile()}.${process.pid}.tmp`
223
+ writeFileSync(tmp, JSON.stringify(value))
224
+ renameSync(tmp, cacheFile())
225
+ } catch (err) {
226
+ note(`could not write the tool-schema cache (${errText(err)})`)
227
+ }
228
+ }
229
+
230
+ // ---- resolving / installing the real server ------------------------------------------------------
231
+ /** `bin` may be a string or a map; take the entry named after the package, else the sole one. */
232
+ function binPath(pkgDir) {
233
+ const pkg = readJson(join(pkgDir, "package.json"))
234
+ if (!pkg) return undefined
235
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[spec.name] ?? Object.values(pkg.bin ?? {})[0]
236
+ if (typeof bin !== "string") return undefined
237
+ const abs = join(pkgDir, bin)
238
+ return existsSync(abs) ? { bin: abs, version: pkg.version } : undefined
239
+ }
240
+
241
+ const installRoot = () => join(CACHE_DIR, "pkg", spec.version)
242
+
243
+ /** @returns {{bin: string, version?: string} | undefined} */
244
+ function resolveInstalled() {
245
+ const override = process.env.FRIZZ_BROWSER_MCP_BIN
246
+ if (override && existsSync(override)) return { bin: override }
247
+ const managed = binPath(join(installRoot(), "node_modules", spec.name))
248
+ if (managed) return managed
249
+ // An ambient copy: a layout that vends the package as a real dependency resolves from here.
250
+ try {
251
+ const manifest = createRequire(import.meta.url).resolve(`${spec.name}/package.json`)
252
+ const ambient = binPath(dirname(manifest))
253
+ if (ambient && (spec.version === "latest" || ambient.version === spec.version)) return ambient
254
+ } catch {
255
+ /* not resolvable from here — normal */
256
+ }
257
+ return undefined
258
+ }
259
+
260
+ /**
261
+ * `npm` as an argv, preferring the npm-cli.js beside this node over anything on PATH: the worker's
262
+ * PATH varies by launch context, and this avoids `npm.cmd` + a shell on Windows entirely.
263
+ */
264
+ function npmArgv() {
265
+ const cli = join(dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")
266
+ const sibling = join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js")
267
+ for (const candidate of [cli, sibling]) if (existsSync(candidate)) return [process.execPath, candidate]
268
+ return [process.platform === "win32" ? "npm.cmd" : "npm"]
269
+ }
270
+
271
+ /**
272
+ * Staging directories this process created, so an orderly exit does not leave one behind. A SIGKILL
273
+ * still can, which is what sweepStaleStaging is for.
274
+ */
275
+ const ownStaging = new Set()
276
+
277
+ /**
278
+ * Delete `.install-*` directories no live install can still own. An install interrupted before its
279
+ * rename — the client killing a probe connection, a machine going to sleep — leaves 13 MB behind
280
+ * forever otherwise, and it happened on the very first real worker run.
281
+ */
282
+ function sweepStaleStaging() {
283
+ const cutoff = Date.now() - 60 * 60 * 1000
284
+ try {
285
+ for (const entry of readdirSync(CACHE_DIR)) {
286
+ if (!entry.startsWith(".install-")) continue
287
+ const path = join(CACHE_DIR, entry)
288
+ if (ownStaging.has(path)) continue
289
+ try {
290
+ if (statSync(path).mtimeMs < cutoff) rmSync(path, { recursive: true, force: true })
291
+ } catch {
292
+ /* a directory that vanished under us is exactly the outcome we wanted */
293
+ }
294
+ }
295
+ } catch {
296
+ /* no cache dir yet */
297
+ }
298
+ }
299
+
300
+ /** @returns {Promise<{bin: string, version?: string}>} */
301
+ async function ensureInstalled() {
302
+ const found = resolveInstalled()
303
+ if (found) return found
304
+ mkdirSync(CACHE_DIR, { recursive: true })
305
+ sweepStaleStaging()
306
+ const staging = mkdtempSync(join(CACHE_DIR, ".install-"))
307
+ ownStaging.add(staging)
308
+ const [cmd, ...pre] = npmArgv()
309
+ const args = [
310
+ ...pre,
311
+ "install",
312
+ "--prefix",
313
+ staging,
314
+ `${spec.name}@${spec.version}`,
315
+ "--no-audit",
316
+ "--no-fund",
317
+ "--no-package-lock",
318
+ "--loglevel=error",
319
+ ]
320
+ note(`installing ${spec.name}@${spec.version} (one time, into ${installRoot()})`)
321
+ try {
322
+ await run(cmd, args, INSTALL_BUDGET_MS)
323
+ mkdirSync(dirname(installRoot()), { recursive: true })
324
+ try {
325
+ renameSync(staging, installRoot())
326
+ } catch {
327
+ // Another worker won the race and landed its copy first. Its tree is as good as ours.
328
+ rmSync(staging, { recursive: true, force: true })
329
+ }
330
+ ownStaging.delete(staging)
331
+ } catch (err) {
332
+ rmSync(staging, { recursive: true, force: true })
333
+ ownStaging.delete(staging)
334
+ throw new Error(`could not install ${spec.name}@${spec.version}: ${errText(err)}`)
335
+ }
336
+ const installed = resolveInstalled()
337
+ if (!installed) throw new Error(`installed ${spec.name}@${spec.version} but found no runnable bin under ${installRoot()}`)
338
+ return installed
339
+ }
340
+
341
+ /** @param {string} cmd @param {string[]} args @param {number} timeoutMs */
342
+ function run(cmd, args, timeoutMs) {
343
+ return new Promise((resolve, reject) => {
344
+ const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] })
345
+ let err = ""
346
+ proc.stdout.on("data", () => {})
347
+ proc.stderr.on("data", (d) => {
348
+ err += d
349
+ })
350
+ const timer = setTimeout(() => {
351
+ proc.kill("SIGKILL")
352
+ reject(new Error(`timed out after ${timeoutMs}ms`))
353
+ }, timeoutMs)
354
+ proc.on("error", (e) => {
355
+ clearTimeout(timer)
356
+ reject(e)
357
+ })
358
+ proc.on("close", (code) => {
359
+ clearTimeout(timer)
360
+ if (code === 0) resolve(undefined)
361
+ else reject(new Error(`exit ${code}${err ? `: ${err.trim().slice(0, 400)}` : ""}`))
362
+ })
363
+ })
364
+ }
365
+
366
+ // ---- the child, and the two-way pipe -------------------------------------------------------------
367
+ /** @type {import("node:child_process").ChildProcessWithoutNullStreams | null} */
368
+ let child = null
369
+ /** Single-flight start. Cleared on failure and on exit so a later call can try again. */
370
+ let starting = null
371
+ /** The client's own `initialize` params, replayed to the child verbatim. See the header. */
372
+ let clientInit = null
373
+ /** A `logging/setLevel` the client set before the child existed, replayed on start. */
374
+ let pendingLogLevel = null
375
+ /** Client request ids currently in flight downstream, so a child crash can answer them. */
376
+ const inFlight = new Set()
377
+ /** Requests THIS process authored on the child's stdin. */
378
+ const internal = new Map()
379
+
380
+ /** @param {unknown} obj */
381
+ function sendChild(obj) {
382
+ child?.stdin.write(JSON.stringify(obj) + "\n")
383
+ }
384
+
385
+ /** @param {string} id @param {string} method @param {unknown} params */
386
+ function childRequest(id, method, params) {
387
+ return new Promise((resolve, reject) => {
388
+ internal.set(id, { resolve, reject })
389
+ sendChild({ jsonrpc: "2.0", id, method, params })
390
+ })
391
+ }
392
+
393
+ function onChildLine(line) {
394
+ let msg
395
+ try {
396
+ msg = JSON.parse(line)
397
+ } catch {
398
+ return // a non-protocol line on stdout is not ours to invent meaning for
399
+ }
400
+ const pending = msg.id !== undefined && msg.id !== null ? internal.get(msg.id) : undefined
401
+ if (pending) {
402
+ internal.delete(msg.id)
403
+ if (msg.error) pending.reject(new Error(msg.error.message ?? "child error"))
404
+ else pending.resolve(msg.result)
405
+ return
406
+ }
407
+ if (msg.id !== undefined && msg.id !== null && msg.method === undefined) inFlight.delete(msg.id)
408
+ send(msg) // responses to the client's requests, the child's own requests, and its notifications
409
+ }
410
+
411
+ function onChildGone(reason) {
412
+ const dead = inFlight.size
413
+ child = null
414
+ starting = null
415
+ for (const { reject } of internal.values()) reject(new Error(reason))
416
+ internal.clear()
417
+ for (const id of inFlight) replyError(id, -32000, `${spec.name} exited before answering: ${reason}`)
418
+ inFlight.clear()
419
+ if (dead) note(`the browser server died with ${dead} call(s) in flight (${reason})`)
420
+ }
421
+
422
+ /** @returns {Promise<void>} */
423
+ function ensureChild() {
424
+ if (child) return Promise.resolve()
425
+ if (starting) return starting
426
+ starting = (async () => {
427
+ const { bin } = await ensureInstalled()
428
+ const proc = spawn(process.execPath, [bin, ...BROWSER_ARGS], { stdio: ["pipe", "pipe", "pipe"] })
429
+ child = proc
430
+ let buf = ""
431
+ proc.stdout.setEncoding("utf8")
432
+ proc.stdout.on("data", (chunk) => {
433
+ buf += chunk
434
+ let nl
435
+ while ((nl = buf.indexOf("\n")) >= 0) {
436
+ const line = buf.slice(0, nl).trim()
437
+ buf = buf.slice(nl + 1)
438
+ if (line) onChildLine(line)
439
+ }
440
+ })
441
+ proc.stderr.on("data", (d) => {
442
+ try {
443
+ process.stderr.write(d)
444
+ } catch {
445
+ /* ignore */
446
+ }
447
+ })
448
+ proc.on("error", (err) => onChildGone(errText(err)))
449
+ proc.on("exit", (code, signal) => onChildGone(signal ? `signal ${signal}` : `exit ${code}`))
450
+ // The CLIENT's handshake, not ours — see the header. Absent (a `tools/call` with no preceding
451
+ // `initialize`, which no real client does) we introduce ourselves honestly.
452
+ const init = clientInit ?? {
453
+ protocolVersion: PROTOCOL_FALLBACK,
454
+ capabilities: {},
455
+ clientInfo: { name: "frizz-browser-mcp", version: "1" },
456
+ }
457
+ const negotiated = await withTimeout(childRequest(INIT_ID, "initialize", init), HANDSHAKE_BUDGET_MS, "the browser server handshake")
458
+ sendChild({ jsonrpc: "2.0", method: "notifications/initialized" })
459
+ if (pendingLogLevel) sendChild({ jsonrpc: "2.0", id: LOG_ID, method: "logging/setLevel", params: { level: pendingLogLevel } })
460
+ void reconcileTools(proc, negotiated)
461
+ })()
462
+ starting.catch(() => {
463
+ // A child that spawned but never finished its handshake is not reusable; drop it so the NEXT call
464
+ // gets a clean attempt rather than an inheritance of this failure.
465
+ starting = null
466
+ try {
467
+ child?.kill()
468
+ } catch {
469
+ /* already gone */
470
+ }
471
+ child = null
472
+ })
473
+ return starting
474
+ }
475
+
476
+ /**
477
+ * The moment the real server exists, its `tools/list` is the truth. Compare it with what we served; on
478
+ * a difference, rewrite the version cache and tell the client to refetch. This is what keeps a stale
479
+ * snapshot from being a permanent lie rather than a one-session approximation.
480
+ */
481
+ async function reconcileTools(proc, negotiated) {
482
+ try {
483
+ const result = await childRequest("frizz-browser-mcp/tools", "tools/list", {})
484
+ if (!result?.tools || child !== proc) return
485
+ const served = registry?.tools
486
+ if (served && JSON.stringify(result.tools) === JSON.stringify(served)) return
487
+ writeCache({
488
+ package: spec.name,
489
+ version: spec.version,
490
+ protocolVersion: negotiated?.protocolVersion ?? registry?.protocolVersion,
491
+ capabilities: negotiated?.capabilities ?? registry?.capabilities,
492
+ serverInfo: negotiated?.serverInfo ?? registry?.serverInfo,
493
+ tools: result.tools,
494
+ })
495
+ registry = { ...(registry ?? {}), tools: result.tools }
496
+ if (!served) return // nothing was ever served to this client, so there is nothing to correct
497
+ note(`tool schemas changed under version ${spec.version}; cache refreshed`)
498
+ send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })
499
+ } catch (err) {
500
+ note(`could not reconcile tool schemas (${errText(err)})`)
501
+ }
502
+ }
503
+
504
+ // ---- harvest mode --------------------------------------------------------------------------------
505
+ /** Boot the real server once and read its registry out. Used by `--frizz-harvest` AND by loadRegistry. */
506
+ async function harvest() {
507
+ const { bin, version } = await ensureInstalled()
508
+ const proc = spawn(process.execPath, [bin, ...BROWSER_ARGS], { stdio: ["pipe", "pipe", "ignore"] })
509
+ try {
510
+ let buf = ""
511
+ const pending = new Map()
512
+ proc.stdout.setEncoding("utf8")
513
+ proc.stdout.on("data", (chunk) => {
514
+ buf += chunk
515
+ let nl
516
+ while ((nl = buf.indexOf("\n")) >= 0) {
517
+ const line = buf.slice(0, nl).trim()
518
+ buf = buf.slice(nl + 1)
519
+ if (!line) continue
520
+ let msg
521
+ try {
522
+ msg = JSON.parse(line)
523
+ } catch {
524
+ continue
525
+ }
526
+ const waiter = pending.get(msg.id)
527
+ if (!waiter) continue
528
+ pending.delete(msg.id)
529
+ if (msg.error) waiter.reject(new Error(msg.error.message ?? "error"))
530
+ else waiter.resolve(msg.result)
531
+ }
532
+ })
533
+ const ask = (id, method, params) =>
534
+ new Promise((resolve, reject) => {
535
+ pending.set(id, { resolve, reject })
536
+ proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
537
+ })
538
+ proc.on("exit", () => {
539
+ for (const w of pending.values()) w.reject(new Error("the browser server exited during the harvest"))
540
+ pending.clear()
541
+ })
542
+ const init = await ask(1, "initialize", {
543
+ protocolVersion: PROTOCOL_FALLBACK,
544
+ capabilities: { roots: { listChanged: true } },
545
+ clientInfo: { name: "frizz-browser-mcp", version: "1" },
546
+ })
547
+ proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n")
548
+ const list = await ask(2, "tools/list", {})
549
+ return {
550
+ package: spec.name,
551
+ version: version ?? spec.version,
552
+ protocolVersion: init?.protocolVersion ?? PROTOCOL_FALLBACK,
553
+ capabilities: init?.capabilities ?? { tools: { listChanged: true } },
554
+ serverInfo: init?.serverInfo,
555
+ tools: list?.tools ?? [],
556
+ }
557
+ } finally {
558
+ proc.kill()
559
+ }
560
+ }
561
+
562
+ // ---- helpers -------------------------------------------------------------------------------------
563
+ /** @param {Promise<any>} promise @param {number} ms @param {string} what */
564
+ function withTimeout(promise, ms, what) {
565
+ return new Promise((resolve, reject) => {
566
+ const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms)
567
+ promise.then(
568
+ (v) => {
569
+ clearTimeout(timer)
570
+ resolve(v)
571
+ },
572
+ (e) => {
573
+ clearTimeout(timer)
574
+ reject(e)
575
+ },
576
+ )
577
+ })
578
+ }
579
+
580
+ /** @param {unknown} err */
581
+ function errText(err) {
582
+ return err instanceof Error ? err.message : String(err)
583
+ }
584
+
585
+ // ---- the client side -----------------------------------------------------------------------------
586
+ /** @param {any} msg */
587
+ async function handle(msg) {
588
+ const { id, method, params } = msg ?? {}
589
+ const isNotification = id === undefined || id === null
590
+ if (process.env.FRIZZ_BROWSER_MCP_TRACE) note(`<- ${method ?? "(response)"} id=${String(id)}`)
591
+
592
+ // A RESPONSE from the client (to a `roots/list` or a sampling request the child made) — never ours
593
+ // to interpret, always the child's to receive.
594
+ if (method === undefined) {
595
+ if (child) sendChild(msg)
596
+ return
597
+ }
598
+
599
+ switch (method) {
600
+ case "initialize": {
601
+ clientInit = params
602
+ const reg = peekRegistry()
603
+ const requested = params?.protocolVersion
604
+ reply(id, {
605
+ protocolVersion: typeof requested === "string" ? requested : reg?.protocolVersion ?? PROTOCOL_FALLBACK,
606
+ capabilities: reg?.capabilities ?? { tools: { listChanged: true } },
607
+ serverInfo: reg?.serverInfo ?? { name: spec.name, version: spec.version },
608
+ })
609
+ return
610
+ }
611
+ case "notifications/initialized":
612
+ case "initialized":
613
+ return // notification — no reply, and nothing to start
614
+ case "ping":
615
+ if (!isNotification) reply(id, {})
616
+ return
617
+ case "tools/list": {
618
+ // The whole point: answered WITHOUT the real server, unless it happens to be up already.
619
+ if (child) return forward(msg)
620
+ const reg = await loadRegistry()
621
+ reply(id, { tools: reg.tools })
622
+ return
623
+ }
624
+ case "logging/setLevel":
625
+ pendingLogLevel = params?.level ?? null
626
+ if (child) return forward(msg)
627
+ if (!isNotification) reply(id, {})
628
+ return
629
+ default:
630
+ // ONLY `tools/call` starts the browser. Everything else that reaches here is answered the way the
631
+ // real server answers it — which for this server is `-32601 Method not found`, since it advertises
632
+ // nothing but `logging` and `tools` (verified against 1.7.0 for `server/discover`, `resources/list`
633
+ // and `prompts/list`; all three come back -32601).
634
+ //
635
+ // THIS BRANCH IS WHERE THE WHOLE CHANGE ALMOST DIED. Claude Code opens a short-lived probe
636
+ // connection and sends `server/discover` (id `server-discover-probe-1`) BEFORE the real session.
637
+ // While `default:` meant "start the browser", every worker paid a full install + server boot at
638
+ // session start, in a process the client then killed — which also leaked its half-finished install
639
+ // staging. Caught on 2026-08-19 only by tracing a real worker's proxy (FRIZZ_BROWSER_MCP_TRACE);
640
+ // every unit test and the whole tool-call path passed while it was happening.
641
+ if (isNotification) {
642
+ if (child) sendChild(msg)
643
+ return
644
+ }
645
+ if (method !== "tools/call") {
646
+ if (child) forward(msg)
647
+ else replyError(id, -32601, "Method not found")
648
+ return
649
+ }
650
+ try {
651
+ await ensureChild()
652
+ } catch (err) {
653
+ // A tool that is LISTED but dies when called is worse than one that was never offered, so say
654
+ // exactly what went wrong in the tool result the MODEL reads, not only in a protocol error it
655
+ // never sees.
656
+ reply(id, {
657
+ content: [{ type: "text", text: `frizz could not start ${spec.name}@${spec.version}: ${errText(err)}` }],
658
+ isError: true,
659
+ })
660
+ return
661
+ }
662
+ forward(msg)
663
+ return
664
+ }
665
+ }
666
+
667
+ /** @param {any} msg */
668
+ function forward(msg) {
669
+ if (msg.id !== undefined && msg.id !== null) inFlight.add(msg.id)
670
+ sendChild(msg)
671
+ }
672
+
673
+ // ---- lifecycle -----------------------------------------------------------------------------------
674
+ function shutdown(code) {
675
+ try {
676
+ child?.kill()
677
+ } catch {
678
+ /* already gone */
679
+ }
680
+ process.exit(code)
681
+ }
682
+ process.on("exit", () => {
683
+ try {
684
+ child?.kill()
685
+ } catch {
686
+ /* already gone */
687
+ }
688
+ for (const path of ownStaging) {
689
+ try {
690
+ rmSync(path, { recursive: true, force: true })
691
+ } catch {
692
+ /* best effort on the way out */
693
+ }
694
+ }
695
+ })
696
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => shutdown(0))
697
+
698
+ if (HARVEST) {
699
+ harvest().then(
700
+ (result) => {
701
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n")
702
+ shutdown(0)
703
+ },
704
+ (err) => {
705
+ note(`harvest failed: ${errText(err)}`)
706
+ shutdown(1)
707
+ },
708
+ )
709
+ } else {
710
+ // NDJSON reader: buffer stdin, dispatch each complete line. Messages never contain raw newlines.
711
+ let buf = ""
712
+ process.stdin.setEncoding("utf8")
713
+ process.stdin.on("data", (chunk) => {
714
+ buf += chunk
715
+ let nl
716
+ while ((nl = buf.indexOf("\n")) >= 0) {
717
+ const line = buf.slice(0, nl).trim()
718
+ buf = buf.slice(nl + 1)
719
+ if (!line) continue
720
+ let msg
721
+ try {
722
+ msg = JSON.parse(line)
723
+ } catch {
724
+ continue // ignore unparseable lines
725
+ }
726
+ void handle(msg)
727
+ }
728
+ })
729
+ process.stdin.on("end", () => shutdown(0))
730
+ }