@hover-dev/core 0.14.1 → 0.16.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 (99) hide show
  1. package/README.md +73 -1
  2. package/dist/agents/aider.d.ts.map +1 -1
  3. package/dist/agents/aider.js +6 -14
  4. package/dist/agents/claude.d.ts.map +1 -1
  5. package/dist/agents/claude.js +14 -0
  6. package/dist/agents/codex.d.ts.map +1 -1
  7. package/dist/agents/codex.js +10 -4
  8. package/dist/agents/cursor.d.ts.map +1 -1
  9. package/dist/agents/cursor.js +8 -17
  10. package/dist/agents/gemini.d.ts.map +1 -1
  11. package/dist/agents/gemini.js +3 -14
  12. package/dist/agents/invoke.d.ts.map +1 -1
  13. package/dist/agents/invoke.js +10 -1
  14. package/dist/agents/qwen.d.ts.map +1 -1
  15. package/dist/agents/qwen.js +3 -14
  16. package/dist/agents/shared.d.ts +28 -0
  17. package/dist/agents/shared.d.ts.map +1 -0
  18. package/dist/agents/shared.js +35 -0
  19. package/dist/agents/types.d.ts +11 -0
  20. package/dist/agents/types.d.ts.map +1 -1
  21. package/dist/mcp/sourceFence.d.ts +23 -0
  22. package/dist/mcp/sourceFence.d.ts.map +1 -0
  23. package/dist/mcp/sourceFence.js +75 -0
  24. package/dist/mcp/sourceServer.d.ts +3 -0
  25. package/dist/mcp/sourceServer.d.ts.map +1 -0
  26. package/dist/mcp/sourceServer.js +116 -0
  27. package/dist/playwright/preflight.d.ts.map +1 -1
  28. package/dist/playwright/preflight.js +6 -1
  29. package/dist/playwright/raiseWindow.d.ts.map +1 -1
  30. package/dist/playwright/raiseWindow.js +22 -3
  31. package/dist/playwright/resolveMcpConfig.d.ts +11 -0
  32. package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
  33. package/dist/playwright/resolveMcpConfig.js +17 -3
  34. package/dist/plugin-api.d.ts +7 -0
  35. package/dist/plugin-api.d.ts.map +1 -1
  36. package/dist/runSession.d.ts +42 -0
  37. package/dist/runSession.d.ts.map +1 -0
  38. package/dist/runSession.js +81 -0
  39. package/dist/service/cdpHandlers.d.ts +3 -7
  40. package/dist/service/cdpHandlers.d.ts.map +1 -1
  41. package/dist/service/cdpHandlers.js +4 -16
  42. package/dist/service/cdpHint.d.ts.map +1 -1
  43. package/dist/service/cdpHint.js +30 -14
  44. package/dist/service/conventions.d.ts +8 -0
  45. package/dist/service/conventions.d.ts.map +1 -0
  46. package/dist/service/conventions.js +42 -0
  47. package/dist/service/saveHandlers.d.ts +10 -13
  48. package/dist/service/saveHandlers.d.ts.map +1 -1
  49. package/dist/service/saveHandlers.js +9 -25
  50. package/dist/service/types.d.ts +5 -0
  51. package/dist/service/types.d.ts.map +1 -1
  52. package/dist/service.d.ts +13 -4
  53. package/dist/service.d.ts.map +1 -1
  54. package/dist/service.js +264 -148
  55. package/dist/skills/writeSkill.d.ts +12 -35
  56. package/dist/skills/writeSkill.d.ts.map +1 -1
  57. package/dist/skills/writeSkill.js +10 -166
  58. package/dist/specs/detectSharedFlows.d.ts +35 -0
  59. package/dist/specs/detectSharedFlows.d.ts.map +1 -0
  60. package/dist/specs/detectSharedFlows.js +171 -0
  61. package/dist/specs/extractPageObjects.d.ts +18 -0
  62. package/dist/specs/extractPageObjects.d.ts.map +1 -0
  63. package/dist/specs/extractPageObjects.js +98 -0
  64. package/dist/specs/generatePageObject.d.ts +29 -0
  65. package/dist/specs/generatePageObject.d.ts.map +1 -0
  66. package/dist/specs/generatePageObject.js +149 -0
  67. package/dist/specs/listSpecs.d.ts +12 -0
  68. package/dist/specs/listSpecs.d.ts.map +1 -1
  69. package/dist/specs/listSpecs.js +27 -2
  70. package/dist/specs/optimizationSuggestion.d.ts +26 -0
  71. package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
  72. package/dist/specs/optimizationSuggestion.js +28 -0
  73. package/dist/specs/optimizeSpec.d.ts +42 -0
  74. package/dist/specs/optimizeSpec.d.ts.map +1 -0
  75. package/dist/specs/optimizeSpec.js +188 -0
  76. package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
  77. package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
  78. package/dist/specs/optimizeSpecWithAgent.js +40 -0
  79. package/dist/specs/pageObjectManifest.d.ts +20 -0
  80. package/dist/specs/pageObjectManifest.d.ts.map +1 -0
  81. package/dist/specs/pageObjectManifest.js +40 -0
  82. package/dist/specs/seeds.d.ts +36 -0
  83. package/dist/specs/seeds.d.ts.map +1 -0
  84. package/dist/specs/seeds.js +74 -0
  85. package/dist/specs/sidecar.d.ts +25 -0
  86. package/dist/specs/sidecar.d.ts.map +1 -0
  87. package/dist/specs/sidecar.js +38 -0
  88. package/dist/specs/softBatch.d.ts +14 -0
  89. package/dist/specs/softBatch.d.ts.map +1 -0
  90. package/dist/specs/softBatch.js +177 -0
  91. package/dist/specs/text.d.ts +17 -0
  92. package/dist/specs/text.d.ts.map +1 -0
  93. package/dist/specs/text.js +24 -0
  94. package/dist/specs/writeCaseCsv.d.ts.map +1 -1
  95. package/dist/specs/writeCaseCsv.js +2 -8
  96. package/dist/specs/writeSpec.d.ts +50 -0
  97. package/dist/specs/writeSpec.d.ts.map +1 -1
  98. package/dist/specs/writeSpec.js +251 -84
  99. package/package.json +5 -3
package/README.md CHANGED
@@ -26,6 +26,78 @@ The local Node service. Owns:
26
26
 
27
27
  To add an agent: implement an `AgentDescriptor`, register it in `registry.ts`. Done.
28
28
 
29
+ ## Spec generation (specs/)
30
+
31
+ A verified session crystallizes into a standard `@playwright/test` file under
32
+ `<devRoot>/__vibe_tests__/`. Translation is **deterministic — no LLM on the
33
+ per-save path** (reproducible by construction):
34
+
35
+ - `writeSpec.ts` walks the captured `browser_*` actions and emits one Playwright
36
+ call each (`getByRole` / `getByLabel` / `getByText` selectors — never XPath).
37
+ A few high-frequency multi-action shapes are hardcoded (popup / new-tab →
38
+ `Promise.all([context.waitForEvent('page'), …click()])`). An action with no
39
+ single-step translation (file upload, drag, …) leaves a structured
40
+ `// hover:optimizable: <tool>` marker rather than a `// TODO` — the draft stays
41
+ runnable around it. `countOptimizableMarkers()` reads the count back; `listSpecs`
42
+ surfaces it as `SpecSummary.optimizableCount`.
43
+ - Alongside the `.spec.ts`, a **sidecar** is written to
44
+ `.hover/<slug>.json` — the structured `SpecStep[]` + observed signals. This is
45
+ the machine-readable behavior record the optimization pass reads (it keeps the
46
+ spec itself clean).
47
+
48
+ ### Optional optimization pass (F7)
49
+
50
+ The **service** (never the sandboxed browser agent) can optionally run an LLM
51
+ **codegen** call over a draft to polish it — chiefly to add assertions for the
52
+ feedback the session observed. Its input is data the service already holds (the
53
+ draft + sidecar + relevant seeds), not live page content, so it sits outside the
54
+ agent's prompt-injection surface and needs no filesystem access. It writes an
55
+ **optimization candidate** to `.hover/optimized/<slug>.spec.ts.draft` (never
56
+ `*.spec.ts`, so the test runner can't collect an unreviewed candidate). A human
57
+ promotes or discards it via diff — **the deterministic original is always
58
+ preserved**. Off by default.
59
+
60
+ ### Seed library — extending translation (`.hover/rules/`)
61
+
62
+ The optimization pass generalizes from **seeds**: human-written worked examples
63
+ of "captured steps → the Playwright code they should produce." This is how
64
+ coverage of new multi-step patterns grows **without core changes** — you (or the
65
+ community) drop a JSON file; the pass picks it up as few-shot.
66
+
67
+ A seed lives at `<projectRoot>/.hover/rules/<name>.json` and matches
68
+ [`src/specs/seed.schema.json`](src/specs/seed.schema.json):
69
+
70
+ ```json
71
+ {
72
+ "name": "oauth-popup",
73
+ "signature": ["browser_click", "browser_tabs:select"],
74
+ "note": "sign in through a provider popup that opens a new tab",
75
+ "example": {
76
+ "steps": [
77
+ { "tool": "browser_click", "element": "Sign in with Google button" },
78
+ { "tool": "browser_tabs", "action": "select", "idx": 1 }
79
+ ],
80
+ "code": "const [popup] = await Promise.all([\n context.waitForEvent('page'),\n page.getByRole('button', { name: 'Sign in with Google' }).click(),\n]);\nawait popup.getByLabel('Email').fill('user@example.com');"
81
+ }
82
+ }
83
+ ```
84
+
85
+ - **`signature`** is a cheap relevance filter only — `relevantSeeds()` keeps a
86
+ seed if any of its base tools (`browser_tabs:select` → `browser_tabs`) appears
87
+ in the spec being optimized. It is **not** exact-matched.
88
+ - **`code`** must obey the same rules as generated specs: semantic selectors, no
89
+ XPath, no `waitForTimeout`.
90
+ - **Built-in seeds** ship in `src/specs/seeds.ts` (`BUILTIN_SEEDS`). The bar to
91
+ be built-in is high — only **highly certain**, app-agnostic, deterministic
92
+ patterns qualify (currently just `download`). Semantic / judgement-based
93
+ optimizations (e.g. *which* feedback text to assert) are not seeds — they're
94
+ standing instructions in the prompt. Popup is hardcoded in `writeSpec.ts`, not
95
+ a seed. Speculative or project-specific patterns belong in your own
96
+ `.hover/rules/`, where the bar is your call.
97
+
98
+ `readSeeds(projectRoot)` returns built-ins + your `.hover/rules/*.json`
99
+ (malformed files are skipped, not fatal).
100
+
29
101
  ## Smoke test
30
102
 
31
103
  ```bash
@@ -56,7 +128,7 @@ Environment variables:
56
128
  The `claude -p` invocation is locked down so Claude can only drive the browser:
57
129
 
58
130
  - `--strict-mcp-config` — ignore any MCP servers in `~/.claude/` or `.mcp.json`
59
- - `--allowedTools mcp__playwright Skill` — only Playwright MCP and the Skill tool are callable
131
+ - `--allowedTools mcp__playwright` — only Playwright MCP is callable
60
132
  - `--disallowedTools Bash Edit Write Read Grep Glob Task WebFetch WebSearch EnterWorktree CronCreate …` — every built-in tool explicitly denied (full list in `CLAUDE_DEFAULT_DISALLOWED_TOOLS` in `claude.ts`)
61
133
  - `--permission-mode dontAsk` — anything not whitelisted aborts the run
62
134
  - `--max-budget-usd <n>` — optional hard $ ceiling per session (no default; pass `maxBudgetUsd` in plugin options or via the CLI flag to enable)
@@ -1 +1 @@
1
- {"version":3,"file":"aider.d.ts","sourceRoot":"","sources":["../../src/agents/aider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AAsH3F,eAAO,MAAM,UAAU,EAAE,eA0GxB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;CAS9B,CAAC"}
1
+ {"version":3,"file":"aider.d.ts","sourceRoot":"","sources":["../../src/agents/aider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AA8G3F,eAAO,MAAM,UAAU,EAAE,eA0GxB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;CAS9B,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { HOVER_PROMPT_PREFACE } from './shared.js';
1
2
  function aiderState(state) {
2
3
  if (typeof state.runningLines !== 'number') {
3
4
  state.runningLines = 0;
@@ -13,18 +14,9 @@ function resetAiderCounters(s) {
13
14
  s.sawErrorEvent = false;
14
15
  s.runningSessionId = undefined;
15
16
  }
16
- /**
17
- * Aider has no system-prompt flag, so we prepend this preface to the user
18
- * prompt (same approach as cursor.ts). The agent treats it as the leading
19
- * user-message text.
20
- */
21
- const AIDER_PROMPT_PREFACE = [
22
- 'You are operating in Hover, a browser-testing tool.',
23
- 'Use ONLY the MCP playwright tools (prefixed `mcp__playwright__` / `mcp__hover-playwright__`) to drive the browser.',
24
- 'Do NOT use shell, file-edit, web-search, or any other built-in tool.',
25
- 'Do NOT navigate to a URL the user is already on; check the page state via `browser_snapshot` first.',
26
- 'When the task is complete, emit a short summary and stop.',
27
- ].join(' ');
17
+ // Aider has no system-prompt flag, so we prepend the standing HOVER-mode
18
+ // preface (HOVER_PROMPT_PREFACE, from shared.ts) to the user prompt (same
19
+ // approach as cursor.ts). The agent treats it as the leading user-message text.
28
20
  /**
29
21
  * Lines we treat as noise and drop instead of surfacing as text events.
30
22
  * Aider chatters with status lines that would clutter the widget panel.
@@ -76,8 +68,8 @@ export const aiderAgent = {
76
68
  // to the prompt. Aider has no --append-system-prompt flag, so this is
77
69
  // the closest functional analogue (same trick as cursor.ts).
78
70
  const preface = opts.appendSystemPrompt && opts.appendSystemPrompt.trim().length > 0
79
- ? `${AIDER_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
80
- : AIDER_PROMPT_PREFACE;
71
+ ? `${HOVER_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
72
+ : HOVER_PROMPT_PREFACE;
81
73
  const finalPrompt = `${preface}\n\n${opts.prompt}`;
82
74
  const args = ['--message', finalPrompt];
83
75
  // Auto-confirm every prompt so the run doesn't hang.
@@ -1 +1 @@
1
- {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/agents/claude.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA2C,MAAM,YAAY,CAAC;AA6G3F,eAAO,MAAM,WAAW,EAAE,eA0HzB,CAAC"}
1
+ {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/agents/claude.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA2C,MAAM,YAAY,CAAC;AA0H3F,eAAO,MAAM,WAAW,EAAE,eA2HzB,CAAC"}
@@ -60,6 +60,19 @@ const CLAUDE_DEFAULT_DISALLOWED_TOOLS = [
60
60
  'Monitor', 'TaskOutput', 'TaskStop',
61
61
  'AskUserQuestion',
62
62
  'ShareOnboardingGuide',
63
+ // Skills are loaded independently of the --allowedTools allow-list, so an
64
+ // allow-list of `mcp__playwright` does NOT block the `Skill` tool. Left
65
+ // through, the agent burns a turn "checking for a project skill first" and
66
+ // pollutes the crystallized spec with a junk `When · Skill` step. Deny it.
67
+ 'Skill',
68
+ // Playwright MCP's arbitrary-JS tools. browser_run_code_unsafe /
69
+ // browser_evaluate run any JS in the page — a real prompt-injection exfil
70
+ // path (fetch a token out, read localStorage) that punches through the
71
+ // "Playwright MCP only" sandbox, and their output can't be translated into
72
+ // a deterministic Playwright spec anyway (it lands as a `// TODO`). Agents
73
+ // drive via click/fill/select and read state via snapshot instead.
74
+ 'mcp__playwright__browser_run_code_unsafe',
75
+ 'mcp__playwright__browser_evaluate',
63
76
  ];
64
77
  export const claudeAgent = {
65
78
  id: 'claude',
@@ -68,6 +81,7 @@ export const claudeAgent = {
68
81
  streamFormat: 'stream-json',
69
82
  sandboxStrength: 'hard',
70
83
  defaultDisallowedTools: CLAUDE_DEFAULT_DISALLOWED_TOOLS,
84
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
71
85
  display: {
72
86
  label: 'Claude Code',
73
87
  tagline: 'Anthropic — best-in-class browser driving, hard tool sandbox',
@@ -1 +1 @@
1
- {"version":3,"file":"codex.d.ts","sourceRoot":"","sources":["../../src/agents/codex.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AAmK3F,eAAO,MAAM,UAAU,EAAE,eAyJxB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
1
+ {"version":3,"file":"codex.d.ts","sourceRoot":"","sources":["../../src/agents/codex.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AAoK3F,eAAO,MAAM,UAAU,EAAE,eA6JxB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { stripMcpPrefix } from './shared.js';
1
2
  /**
2
3
  * Pricing per million tokens. Keep in lockstep with claude.ts's table —
3
4
  * approximate published OpenAI rates as of 2026. We are deliberately
@@ -16,6 +17,9 @@ const PRICE_PER_M_USD = {
16
17
  'gpt-4o': { in: 2.5, out: 10 },
17
18
  'gpt-4': { in: 30, out: 60 },
18
19
  };
20
+ // `modelHint` is currently always passed as undefined — the parser can't see
21
+ // the invocation's --model — so the default tier below is what gets used. The
22
+ // parameter is kept so a future caller that does have the model id can pass it.
19
23
  function estimateCostUsd(modelHint, usage) {
20
24
  const m = (modelHint ?? 'gpt-5.5').toLowerCase();
21
25
  // Match by longest-prefix so 'gpt-5.5-mini' picks up the 'gpt-5.5' tier.
@@ -28,7 +32,6 @@ function codexState(state) {
28
32
  if (typeof state.runningCost !== 'number') {
29
33
  state.runningCost = 0;
30
34
  state.runningTurns = 0;
31
- state.runningModel = undefined;
32
35
  state.runningSessionId = undefined;
33
36
  state.lastAgentMessage = undefined;
34
37
  state.sawErrorEvent = false;
@@ -39,7 +42,6 @@ function codexState(state) {
39
42
  function resetCodexCounters(s) {
40
43
  s.runningCost = 0;
41
44
  s.runningTurns = 0;
42
- s.runningModel = undefined;
43
45
  s.runningSessionId = undefined;
44
46
  s.lastAgentMessage = undefined;
45
47
  s.sawErrorEvent = false;
@@ -59,6 +61,7 @@ export const codexAgent = {
59
61
  protocol: 'argv',
60
62
  streamFormat: 'json-lines',
61
63
  sandboxStrength: 'soft',
64
+ apiKeyEnv: 'OPENAI_API_KEY',
62
65
  display: {
63
66
  label: 'OpenAI Codex',
64
67
  tagline: 'OpenAI — soft sandbox (no built-in tool deny-list)',
@@ -127,7 +130,7 @@ export const codexAgent = {
127
130
  // The exact field names aren't published. Read defensively: prefer
128
131
  // `name`, fall back to `tool`. Same for input.
129
132
  const rawName = it.name ?? it.tool ?? '';
130
- const tool = rawName.replace(/^mcp__playwright__/, '').replace(/^mcp__hover-playwright__/, '');
133
+ const tool = stripMcpPrefix(rawName);
131
134
  out.push({ kind: 'tool_use', tool, input: it.input ?? it.arguments, costUsdSnapshot: s.runningCost });
132
135
  }
133
136
  else if (it.type === 'command_execution') {
@@ -157,7 +160,10 @@ export const codexAgent = {
157
160
  if (ev.type === 'turn.completed') {
158
161
  s.runningTurns += 1;
159
162
  if (ev.usage) {
160
- s.runningCost += estimateCostUsd(s.runningModel, ev.usage);
163
+ // The parser has no access to the invocation's --model, so we let
164
+ // estimateCostUsd fall back to its fixed default tier. Cost is a
165
+ // high-water "should I hit Stop now" signal, not an invoice.
166
+ s.runningCost += estimateCostUsd(undefined, ev.usage);
161
167
  }
162
168
  out.push({ kind: 'usage', costUsd: s.runningCost, turns: s.runningTurns });
163
169
  return out;
@@ -1 +1 @@
1
- {"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../../src/agents/cursor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AAoL3F,eAAO,MAAM,WAAW,EAAE,eAuJzB,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
1
+ {"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../../src/agents/cursor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AA2K3F,eAAO,MAAM,WAAW,EAAE,eAuJzB,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { HOVER_PROMPT_PREFACE, stripMcpPrefix } from './shared.js';
1
2
  function cursorState(state) {
2
3
  if (typeof state.runningTurns !== 'number') {
3
4
  state.runningTurns = 0;
@@ -40,9 +41,7 @@ function extractToolName(tc) {
40
41
  null;
41
42
  const kindFromKey = wrapperKey ? wrapperKey.replace(/ToolCall$/, '') : 'unknown';
42
43
  const rawName = innerName || kindFromKey;
43
- const tool = rawName
44
- .replace(/^mcp__playwright__/, '')
45
- .replace(/^mcp__hover-playwright__/, '');
44
+ const tool = stripMcpPrefix(rawName);
46
45
  const input = (inner && typeof inner === 'object' && 'input' in inner && inner.input) ||
47
46
  (inner && typeof inner === 'object' && 'arguments' in inner && inner.arguments) ||
48
47
  (inner && typeof inner === 'object' && 'args' in inner && inner.args) ||
@@ -62,18 +61,10 @@ function detectToolError(tc) {
62
61
  return true;
63
62
  return false;
64
63
  }
65
- /**
66
- * The closest analogue Cursor has to claude's --append-system-prompt or
67
- * codex's developer_instructions. Since there is no CLI flag, we prepend
68
- * this to the user prompt so the agent sees it as the leading instruction.
69
- */
70
- const CURSOR_PROMPT_PREFACE = [
71
- 'You are operating in Hover, a browser-testing tool.',
72
- 'Use ONLY the MCP playwright tools (prefixed `mcp__playwright__` / `mcp__hover-playwright__`) to drive the browser.',
73
- 'Do NOT use shell, file-edit, web-search, or any other built-in tool.',
74
- 'Do NOT navigate to a URL the user is already on; check the page state via `browser_snapshot` first.',
75
- 'When the task is complete, emit a short summary and stop.',
76
- ].join(' ');
64
+ // The closest analogue Cursor has to claude's --append-system-prompt or
65
+ // codex's developer_instructions is prepending the standing HOVER-mode
66
+ // preface (HOVER_PROMPT_PREFACE, from shared.ts) to the user prompt so the
67
+ // agent sees it as the leading instruction. There is no CLI flag for it.
77
68
  export const cursorAgent = {
78
69
  id: 'cursor',
79
70
  binName: 'cursor-agent',
@@ -92,8 +83,8 @@ export const cursorAgent = {
92
83
  // Cursor has to claude's --append-system-prompt / codex's
93
84
  // developer_instructions, because Cursor exposes no CLI flag for it.
94
85
  const preface = opts.appendSystemPrompt && opts.appendSystemPrompt.trim().length > 0
95
- ? `${CURSOR_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
96
- : CURSOR_PROMPT_PREFACE;
86
+ ? `${HOVER_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
87
+ : HOVER_PROMPT_PREFACE;
97
88
  const finalPrompt = `${preface}\n\n${opts.prompt}`;
98
89
  const args = ['-p', finalPrompt];
99
90
  // NDJSON streaming output.
@@ -1 +1 @@
1
- {"version":3,"file":"gemini.d.ts","sourceRoot":"","sources":["../../src/agents/gemini.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AA4K3F,eAAO,MAAM,WAAW,EAAE,eAiJzB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
1
+ {"version":3,"file":"gemini.d.ts","sourceRoot":"","sources":["../../src/agents/gemini.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AA+J3F,eAAO,MAAM,WAAW,EAAE,eAiJzB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { HOVER_PROMPT_PREFACE, stripMcpPrefix } from './shared.js';
1
2
  function geminiState(state) {
2
3
  if (typeof state.runningTurns !== 'number') {
3
4
  state.runningTurns = 0;
@@ -17,11 +18,6 @@ function resetGeminiCounters(s) {
17
18
  s.sawErrorEvent = false;
18
19
  s.toolNameByUseId.clear();
19
20
  }
20
- /** Strip the `mcp__playwright__` / `mcp__hover-playwright__` prefix so tool
21
- * names match the normalised names claude / codex / cursor / qwen emit. */
22
- function stripMcpPrefix(raw) {
23
- return raw.replace(/^mcp__playwright__/, '').replace(/^mcp__hover-playwright__/, '');
24
- }
25
21
  /**
26
22
  * Extract assistant text from a `message` event whose `content` may be a
27
23
  * plain string OR an array of `{type:'text', text}` content blocks. Gemini's
@@ -41,13 +37,6 @@ function extractMessageText(ev) {
41
37
  }
42
38
  return undefined;
43
39
  }
44
- const GEMINI_PROMPT_PREFACE = [
45
- 'You are operating in Hover, a browser-testing tool.',
46
- 'Use ONLY the MCP playwright tools (prefixed `mcp__playwright__` / `mcp__hover-playwright__`) to drive the browser.',
47
- 'Do NOT use shell, file-edit, web-search, or any other built-in tool.',
48
- 'Do NOT navigate to a URL the user is already on; check the page state via `browser_snapshot` first.',
49
- 'When the task is complete, emit a short summary and stop.',
50
- ].join(' ');
51
40
  export const geminiAgent = {
52
41
  id: 'gemini',
53
42
  binName: 'gemini',
@@ -65,8 +54,8 @@ export const geminiAgent = {
65
54
  // GEMINI_SYSTEM_MD env var which writes a file). Prepend the HOVER-mode
66
55
  // preface to the prompt instead — same pattern as cursor.ts / aider.ts.
67
56
  const preface = opts.appendSystemPrompt && opts.appendSystemPrompt.trim().length > 0
68
- ? `${GEMINI_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
69
- : GEMINI_PROMPT_PREFACE;
57
+ ? `${HOVER_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
58
+ : HOVER_PROMPT_PREFACE;
70
59
  const finalPrompt = `${preface}\n\n${opts.prompt}`;
71
60
  const args = ['-p', finalPrompt];
72
61
  // NDJSON streaming output.
@@ -1 +1 @@
1
- {"version":3,"file":"invoke.d.ts","sourceRoot":"","sources":["../../src/agents/invoke.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAe,MAAM,YAAY,CAAC;AAE1E;;;;;;;;GAQG;AACH,wBAAuB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,aAAa,CAAC,WAAW,CAAC,CAsElF"}
1
+ {"version":3,"file":"invoke.d.ts","sourceRoot":"","sources":["../../src/agents/invoke.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAe,MAAM,YAAY,CAAC;AAE1E;;;;;;;;GAQG;AACH,wBAAuB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,aAAa,CAAC,WAAW,CAAC,CA+ElF"}
@@ -31,7 +31,16 @@ export async function* invokeAgent(opts) {
31
31
  cwd: opts.cwd,
32
32
  // Clear CLAUDECODE so spawning `claude` from inside a Claude Code session
33
33
  // doesn't trip the nested-session guard. Harmless for other agents.
34
- env: { ...process.env, CLAUDECODE: '' },
34
+ // If the caller supplied an API key and the descriptor names a key env var,
35
+ // inject it so the CLI runs on the key instead of a logged-in subscription.
36
+ // The key lives only in this child's env — never logged, never persisted.
37
+ env: {
38
+ ...process.env,
39
+ CLAUDECODE: '',
40
+ ...(opts.apiKey && descriptor.apiKeyEnv
41
+ ? { [descriptor.apiKeyEnv]: opts.apiKey }
42
+ : {}),
43
+ },
35
44
  });
36
45
  const onAbort = () => {
37
46
  if (!child.killed)
@@ -1 +1 @@
1
- {"version":3,"file":"qwen.d.ts","sourceRoot":"","sources":["../../src/agents/qwen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AAiK3F,eAAO,MAAM,SAAS,EAAE,eAqJvB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
1
+ {"version":3,"file":"qwen.d.ts","sourceRoot":"","sources":["../../src/agents/qwen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AAoJ3F,eAAO,MAAM,SAAS,EAAE,eAqJvB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { HOVER_PROMPT_PREFACE, stripMcpPrefix } from './shared.js';
1
2
  function qwenState(state) {
2
3
  if (typeof state.runningTurns !== 'number') {
3
4
  state.runningTurns = 0;
@@ -17,18 +18,6 @@ function resetQwenCounters(s) {
17
18
  s.sawErrorEvent = false;
18
19
  s.toolNameByUseId.clear();
19
20
  }
20
- /** Strip the `mcp__playwright__` / `mcp__hover-playwright__` prefix so tool
21
- * names match the normalised names claude / codex / cursor emit. */
22
- function stripMcpPrefix(raw) {
23
- return raw.replace(/^mcp__playwright__/, '').replace(/^mcp__hover-playwright__/, '');
24
- }
25
- const QWEN_PROMPT_PREFACE = [
26
- 'You are operating in Hover, a browser-testing tool.',
27
- 'Use ONLY the MCP playwright tools (prefixed `mcp__playwright__` / `mcp__hover-playwright__`) to drive the browser.',
28
- 'Do NOT use shell, file-edit, web-search, or any other built-in tool.',
29
- 'Do NOT navigate to a URL the user is already on; check the page state via `browser_snapshot` first.',
30
- 'When the task is complete, emit a short summary and stop.',
31
- ].join(' ');
32
21
  export const qwenAgent = {
33
22
  id: 'qwen',
34
23
  binName: 'qwen',
@@ -62,8 +51,8 @@ export const qwenAgent = {
62
51
  // prepending to the user prompt. Concatenate the standing Hover-mode
63
52
  // preface with whatever the caller appended.
64
53
  const sysPrompt = opts.appendSystemPrompt && opts.appendSystemPrompt.trim().length > 0
65
- ? `${QWEN_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
66
- : QWEN_PROMPT_PREFACE;
54
+ ? `${HOVER_PROMPT_PREFACE} ${opts.appendSystemPrompt}`
55
+ : HOVER_PROMPT_PREFACE;
67
56
  args.push('--append-system-prompt', sysPrompt);
68
57
  // MCP servers configured in ~/.qwen/settings.json — no per-invocation
69
58
  // --mcp-config equivalent. Same constraint as cursor / codex.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Cross-agent helpers shared by the soft-sandbox descriptors
3
+ * (codex / cursor / gemini / qwen / aider).
4
+ *
5
+ * These agents all need the same two things:
6
+ * 1. A standing "HOVER-mode" instruction preface that tells the agent to
7
+ * drive the browser via the Playwright MCP tools only and not to touch
8
+ * its built-in shell / file-edit tools. Each agent injects it through a
9
+ * different channel (cursor / gemini / aider prepend it to the prompt,
10
+ * qwen passes it via --append-system-prompt, codex via
11
+ * `-c developer_instructions=`), so this module owns only the *text*, not
12
+ * the injection.
13
+ * 2. Normalising the `mcp__playwright__` / `mcp__hover-playwright__` prefix
14
+ * off a raw tool name so the emitted tool names line up across agents.
15
+ *
16
+ * codex deliberately does NOT use HOVER_PROMPT_PREFACE — it keeps its own
17
+ * wording ("Do NOT call …", "emit a short agent_message summary …") that is
18
+ * tuned to codex's event vocabulary. See codex.ts.
19
+ */
20
+ /**
21
+ * The standing HOVER-mode instruction shared by cursor / gemini / qwen / aider.
22
+ * codex carries a near-identical but intentionally different variant inline.
23
+ */
24
+ export declare const HOVER_PROMPT_PREFACE: string;
25
+ /** Strip the `mcp__playwright__` / `mcp__hover-playwright__` prefix so tool
26
+ * names match the normalised names every agent emits. */
27
+ export declare function stripMcpPrefix(raw: string): string;
28
+ //# sourceMappingURL=shared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/agents/shared.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB,QAMtB,CAAC;AAEZ;0DAC0D;AAC1D,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cross-agent helpers shared by the soft-sandbox descriptors
3
+ * (codex / cursor / gemini / qwen / aider).
4
+ *
5
+ * These agents all need the same two things:
6
+ * 1. A standing "HOVER-mode" instruction preface that tells the agent to
7
+ * drive the browser via the Playwright MCP tools only and not to touch
8
+ * its built-in shell / file-edit tools. Each agent injects it through a
9
+ * different channel (cursor / gemini / aider prepend it to the prompt,
10
+ * qwen passes it via --append-system-prompt, codex via
11
+ * `-c developer_instructions=`), so this module owns only the *text*, not
12
+ * the injection.
13
+ * 2. Normalising the `mcp__playwright__` / `mcp__hover-playwright__` prefix
14
+ * off a raw tool name so the emitted tool names line up across agents.
15
+ *
16
+ * codex deliberately does NOT use HOVER_PROMPT_PREFACE — it keeps its own
17
+ * wording ("Do NOT call …", "emit a short agent_message summary …") that is
18
+ * tuned to codex's event vocabulary. See codex.ts.
19
+ */
20
+ /**
21
+ * The standing HOVER-mode instruction shared by cursor / gemini / qwen / aider.
22
+ * codex carries a near-identical but intentionally different variant inline.
23
+ */
24
+ export const HOVER_PROMPT_PREFACE = [
25
+ 'You are operating in Hover, a browser-testing tool.',
26
+ 'Use ONLY the MCP playwright tools (prefixed `mcp__playwright__` / `mcp__hover-playwright__`) to drive the browser.',
27
+ 'Do NOT use shell, file-edit, web-search, or any other built-in tool.',
28
+ 'Do NOT navigate to a URL the user is already on; check the page state via `browser_snapshot` first.',
29
+ 'When the task is complete, emit a short summary and stop.',
30
+ ].join(' ');
31
+ /** Strip the `mcp__playwright__` / `mcp__hover-playwright__` prefix so tool
32
+ * names match the normalised names every agent emits. */
33
+ export function stripMcpPrefix(raw) {
34
+ return raw.replace(/^mcp__playwright__/, '').replace(/^mcp__hover-playwright__/, '');
35
+ }
@@ -31,6 +31,11 @@ export interface InvokeOptions {
31
31
  * "the user's current Chrome tab is already on http://localhost:5173/,
32
32
  * don't browser_navigate there". */
33
33
  appendSystemPrompt?: string;
34
+ /** Optional model API key. Injected into the spawned CLI's environment under
35
+ * the descriptor's `apiKeyEnv` var (e.g. ANTHROPIC_API_KEY) so a user without
36
+ * a logged-in subscription can drive Hover with their own key. Never logged,
37
+ * never persisted server-side — held only for the lifetime of the spawn. */
38
+ apiKey?: string;
34
39
  /** Aborts the spawned child if signaled. Used to stop an orphan run when
35
40
  * the WebSocket caller disconnects (e.g. user reloads the dev page). */
36
41
  signal?: AbortSignal;
@@ -151,6 +156,12 @@ export interface AgentDescriptor {
151
156
  * per-CLI deny list live alongside its descriptor instead of as a magic
152
157
  * array in the service. Soft-sandbox agents leave this undefined. */
153
158
  defaultDisallowedTools?: readonly string[];
159
+ /** Environment variable this CLI reads its model API key from
160
+ * (claude: ANTHROPIC_API_KEY, codex: OPENAI_API_KEY). When set and the
161
+ * caller supplies `InvokeOptions.apiKey`, the key is injected into the spawn
162
+ * env so the user can run on a raw key instead of a logged-in subscription.
163
+ * Undefined for agents that have no API-key env path. */
164
+ apiKeyEnv?: string;
154
165
  buildArgs(opts: InvokeOptions): string[];
155
166
  /**
156
167
  * Parse a single line of agent stdout into normalised InvokeEvents.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/agents/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/agents/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ export interface FenceOk {
2
+ ok: true;
3
+ /** Absolute, root-anchored path the server may stat/read (after realpath). */
4
+ abs: string;
5
+ /** POSIX-style path relative to the root — safe to echo back to the agent. */
6
+ rel: string;
7
+ }
8
+ export interface FenceErr {
9
+ ok: false;
10
+ reason: string;
11
+ }
12
+ /**
13
+ * Resolve `input` against `root`, refusing anything outside the root or matching
14
+ * a secret pattern. `input` is treated as relative to the root; an absolute
15
+ * input is resolved too but will fail the containment check unless it happens to
16
+ * live under the root (the agent should pass repo-relative paths).
17
+ */
18
+ export declare function resolveSourcePath(root: string, input: string): FenceOk | FenceErr;
19
+ /** True if a resolved-and-realpathed absolute path is still inside the root.
20
+ * The server calls this AFTER realpath to defeat symlink escape (a symlink
21
+ * whose lexical path passed resolveSourcePath but points outside the root). */
22
+ export declare function isWithinRoot(root: string, realAbs: string): boolean;
23
+ //# sourceMappingURL=sourceFence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sourceFence.d.ts","sourceRoot":"","sources":["../../src/mcp/sourceFence.ts"],"names":[],"mappings":"AAgCA,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,IAAI,CAAC;IACT,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;CACb;AACD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAsBjF;AAED;;gFAEgF;AAChF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAInE"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The path fence for the opt-in `read_source` capability (codeContext).
3
+ *
4
+ * Giving the agent the ability to read source is the ONE place Hover relaxes
5
+ * its "the agent only touches the browser" rule, so the fence is the whole
6
+ * security story. It must guarantee, on every call, that a caller-supplied path:
7
+ * 1. resolves to a location INSIDE the project root (no `..` / absolute-path
8
+ * escape — symlink escape is caught by the server's realpath re-check), and
9
+ * 2. is not a credential / secret / VCS / dependency file.
10
+ * Pure + lexical so it's exhaustively unit-testable; the server layers a
11
+ * realpath check + a size/binary guard on top.
12
+ */
13
+ import { resolve, relative, isAbsolute, sep } from 'node:path';
14
+ /** Files we refuse to read even inside the root — credentials, keys, VCS,
15
+ * dependency trees, build caches. Matched against the POSIX-style relative
16
+ * path (so `\` on Windows is normalised first). */
17
+ const SECRET_PATTERNS = [
18
+ /(^|\/)\.env(\.[^/]*)?$/i, // .env, .env.local, .env.production
19
+ /(^|\/)\.git(\/|$)/, // the git dir
20
+ /(^|\/)node_modules(\/|$)/, // dependency tree
21
+ /(^|\/)\.(next|nuxt|svelte-kit|astro|turbo|cache|output|vercel)(\/|$)/, // build caches
22
+ /(^|\/)(dist|build|coverage)(\/|$)/, // build output
23
+ /\.(pem|key|p12|pfx|crt|cer|der|keystore|jks)$/i, // key / cert material
24
+ /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.[^/]*)?$/i, // ssh keys
25
+ /(^|\/)\.(npmrc|netrc|pgpass)$/i, // token-bearing rc files
26
+ /(^|\/)\.(ssh|aws|gnupg|gcloud|kube|docker)(\/|$)/i, // credential dirs
27
+ /(^|\/)secrets?(\/|\.[^/]*$|$)/i, // a secrets dir, or a secret(s).<ext> file
28
+ /(^|\/)credentials?(\/|\.[^/]*$|$)/i, // a credentials dir, or credential(s).<ext>
29
+ /\.(secret|secrets)$/i,
30
+ ];
31
+ /** A path containing a NUL or C0 control char is never a legitimate source file. */
32
+ function hasControlChar(s) {
33
+ for (let i = 0; i < s.length; i++) {
34
+ if (s.charCodeAt(i) < 0x20)
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ /**
40
+ * Resolve `input` against `root`, refusing anything outside the root or matching
41
+ * a secret pattern. `input` is treated as relative to the root; an absolute
42
+ * input is resolved too but will fail the containment check unless it happens to
43
+ * live under the root (the agent should pass repo-relative paths).
44
+ */
45
+ export function resolveSourcePath(root, input) {
46
+ if (typeof input !== 'string' || !input.trim()) {
47
+ return { ok: false, reason: 'path is required' };
48
+ }
49
+ if (hasControlChar(input)) {
50
+ return { ok: false, reason: 'path contains control characters' };
51
+ }
52
+ const rootAbs = resolve(root);
53
+ const abs = resolve(rootAbs, input);
54
+ const rel = relative(rootAbs, abs);
55
+ // Outside the root: relative() returns '' for the root itself, a '..'-prefixed
56
+ // path for an ancestor/sibling, or an absolute path when on a different drive.
57
+ if (rel === '' || rel === '..' || rel.startsWith('..' + sep) || rel.startsWith('../') || isAbsolute(rel)) {
58
+ return { ok: false, reason: 'path escapes the project root' };
59
+ }
60
+ const relPosix = rel.split(sep).join('/');
61
+ for (const pat of SECRET_PATTERNS) {
62
+ if (pat.test(relPosix)) {
63
+ return { ok: false, reason: `refused: "${relPosix}" matches an excluded (secret / build / VCS) pattern` };
64
+ }
65
+ }
66
+ return { ok: true, abs, rel: relPosix };
67
+ }
68
+ /** True if a resolved-and-realpathed absolute path is still inside the root.
69
+ * The server calls this AFTER realpath to defeat symlink escape (a symlink
70
+ * whose lexical path passed resolveSourcePath but points outside the root). */
71
+ export function isWithinRoot(root, realAbs) {
72
+ const rootAbs = resolve(root);
73
+ const rel = relative(rootAbs, realAbs);
74
+ return rel !== '' && rel !== '..' && !rel.startsWith('..' + sep) && !rel.startsWith('../') && !isAbsolute(rel);
75
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=sourceServer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sourceServer.d.ts","sourceRoot":"","sources":["../../src/mcp/sourceServer.ts"],"names":[],"mappings":""}