@reticlehq/server 2.3.0 → 2.4.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 (43) hide show
  1. package/dist/bridge.js +43 -3
  2. package/dist/bridge.test-harness.d.ts +5 -0
  3. package/dist/bridge.test-harness.js +12 -0
  4. package/dist/cli-parse.d.ts +14 -2
  5. package/dist/cli-parse.js +40 -12
  6. package/dist/cli-port.d.ts +3 -0
  7. package/dist/cli-port.js +21 -0
  8. package/dist/cli.js +9 -2
  9. package/dist/flows/decision.d.ts +0 -6
  10. package/dist/flows/decision.js +47 -6
  11. package/dist/flows/flow-tools.js +14 -6
  12. package/dist/init/capabilities.d.ts +17 -0
  13. package/dist/init/capabilities.js +51 -0
  14. package/dist/init/detect.d.ts +25 -0
  15. package/dist/init/detect.js +65 -3
  16. package/dist/init/next-patch.d.ts +41 -0
  17. package/dist/init/next-patch.js +106 -0
  18. package/dist/init/node-io.js +25 -0
  19. package/dist/init/patch-kind.d.ts +20 -0
  20. package/dist/init/patch-kind.js +10 -0
  21. package/dist/init/plan-framework.d.ts +35 -0
  22. package/dist/init/plan-framework.js +217 -0
  23. package/dist/init/plan.d.ts +51 -3
  24. package/dist/init/plan.js +125 -125
  25. package/dist/init/run.d.ts +17 -0
  26. package/dist/init/run.js +207 -16
  27. package/dist/init/slash-command.d.ts +18 -0
  28. package/dist/init/slash-command.js +52 -0
  29. package/dist/init/snippets.d.ts +44 -3
  30. package/dist/init/snippets.js +152 -6
  31. package/dist/init/vite-config.d.ts +4 -10
  32. package/dist/init/vite-config.js +15 -7
  33. package/dist/input/driven-redaction.d.ts +15 -0
  34. package/dist/input/driven-redaction.js +76 -0
  35. package/dist/input/network-detail.d.ts +9 -3
  36. package/dist/input/network-detail.js +35 -15
  37. package/dist/mcp-proxy.d.ts +35 -3
  38. package/dist/mcp-proxy.js +174 -27
  39. package/dist/session/session-manager.js +6 -0
  40. package/dist/session/session.d.ts +6 -0
  41. package/dist/session/session.js +7 -0
  42. package/dist/tools/read-tools.js +4 -0
  43. package/package.json +2 -2
@@ -37,8 +37,21 @@ export function nextConfigManual(configFile) {
37
37
 
38
38
  export default withReticle(nextConfig);`;
39
39
  }
40
- /** The dev-only client component that connects Reticle after hydration. */
41
- export function nextReticleDevFile(port, projectId) {
40
+ /**
41
+ * The dev-only client component that connects Reticle after hydration.
42
+ *
43
+ * The token is not optional. The bridge requires a pairing token even on localhost, and unlike Vite
44
+ * (where the plugin injects it) a Next app has to carry it through `withReticle`, which publishes it
45
+ * as `NEXT_PUBLIC_RETICLE_TOKEN`. This file used to connect with only a projectId, so every Next
46
+ * setup ended at `bridge refused the connection: authentication failed` and no session ever appeared.
47
+ */
48
+ export function nextReticleDevFile(port, projectId, testids = [], stores = []) {
49
+ const base = connectArg(port, projectId);
50
+ const fields = base === '' ? '' : `${base.slice(1, -1).trim()}, `;
51
+ const ids = testids.map((t) => `'${t}'`).join(', ');
52
+ const storeBlock = stores.length === 0
53
+ ? ' // No state library detected. If you add one, register it here — see docs/usage.md.'
54
+ : stores.map((h) => ` // import your store, then: ${h}`).join('\n');
42
55
  return `'use client';
43
56
  import { useEffect } from 'react';
44
57
 
@@ -46,15 +59,131 @@ import { useEffect } from 'react';
46
59
  export function ReticleDev() {
47
60
  useEffect(() => {
48
61
  if (process.env.NODE_ENV !== 'development') return;
49
- void import('@reticlehq/react').then(({ reticle, install }) => {
62
+ void import('@reticlehq/react').then(({ reticle, install, registerCapabilities }) => {
50
63
  install();
51
- reticle.connect(${connectArg(port, projectId)});
64
+ // Both provided by withReticle() in next.config. The bridge rejects a connect with no token;
65
+ // the root makes source paths repo-relative instead of absolute.
66
+ const token = process.env.NEXT_PUBLIC_RETICLE_TOKEN;
67
+ const root = process.env.NEXT_PUBLIC_RETICLE_ROOT;
68
+ reticle.connect({ ${fields}...(token ? { token } : {}), ...(root ? { root } : {}) });
69
+
70
+ // ── Start with ONE flow. ──────────────────────────────────────────────────────────────────
71
+ // Registering a store is the highest-value line here: it lets the agent check what the app
72
+ // BELIEVES, not just what it rendered. Pass the STORE, not \`() => store.getState()\` — the store
73
+ // form wires \`subscribe\` too, so every mutation emits a diff; the getter form is read-only.
74
+ ${storeBlock}
75
+ registerCapabilities({
76
+ testids: [${ids}],${testids.length === 0 ? ' // none found — add data-testid to your key elements' : ''}
77
+ signals: [], // names you pass to reticle.signal()
78
+ stores: [], // the keys you registered above
79
+ });
52
80
  });
53
81
  }, []);
54
82
  return null;
55
83
  }
56
84
  `;
57
85
  }
86
+ /**
87
+ * Astro connect instructions.
88
+ *
89
+ * Astro is Vite-based but SSRs its own HTML, so the plugin's index.html injection never fires, and
90
+ * `vite` is not a direct dependency — so this used to fall through to the generic HTML advice, which
91
+ * tells you to add a connect to an "entry module" Astro does not have, or to bundle the SDK with
92
+ * esbuild. Both are wrong for Astro, and following either gets you nothing.
93
+ *
94
+ * Astro bundles a page `<script>`, so the bare import resolves there. The token has to be inlined by
95
+ * the config because there is no plugin in the page's path to inject it, and `build.target` has to be
96
+ * raised or Astro down-levels the modern SDK bundle and dies on a destructuring transform.
97
+ */
98
+ export function astroManual(port, projectId) {
99
+ const extra = port !== undefined && port !== RETICLE_DEFAULT_PORT ? `\n url: '${bridgeWsUrl(port)}',` : '';
100
+ const id = projectId !== undefined && projectId.length > 0 ? `\n projectId: '${projectId}',` : '';
101
+ return `Astro renders its own HTML, so the connect goes in a page <script> and the pairing token is inlined by the config.
102
+
103
+ 1. In astro.config.mjs — inline the daemon's token and raise the build target:
104
+
105
+ import { readFileSync } from 'node:fs';
106
+ import { homedir } from 'node:os';
107
+ import { join } from 'node:path';
108
+
109
+ function reticleToken() {
110
+ const dir = process.env['RETICLE_PAIRING_TOKEN_DIR'] || join(homedir(), '.reticle');
111
+ try { return readFileSync(join(dir, 'pairing-token'), 'utf8').trim(); } catch { return ''; }
112
+ }
113
+
114
+ export default defineConfig({
115
+ vite: {
116
+ // Astro's default target down-levels the modern SDK bundle and fails on a destructuring transform.
117
+ build: { target: 'es2022' },
118
+ optimizeDeps: { esbuildOptions: { target: 'es2022' } },
119
+ define: {
120
+ __RETICLE_TOKEN__: JSON.stringify(reticleToken()),
121
+ // Without this, source pointers come back as absolute paths from YOUR machine — useless in a
122
+ // report. Every other framework gets it from its build plugin; Astro owns its Vite instance.
123
+ __RETICLE_ROOT__: JSON.stringify(process.cwd()),
124
+ },
125
+ },
126
+ });
127
+
128
+ 2. In your layout (or the page you want instrumented), inside <body>:
129
+
130
+ <script>
131
+ if (import.meta.env.DEV) {
132
+ const token = typeof __RETICLE_TOKEN__ !== 'undefined' ? __RETICLE_TOKEN__ : '';
133
+ const root = typeof __RETICLE_ROOT__ !== 'undefined' ? __RETICLE_ROOT__ : '';
134
+ const { reticle, install } = await import('@reticlehq/react');
135
+ install();
136
+ reticle.connect({${id}${extra}
137
+ ...(token.length > 0 ? { token } : {}),
138
+ ...(root.length > 0 ? { root } : {}),
139
+ });
140
+ }
141
+ </script>
142
+
143
+ Start the daemon BEFORE \`astro dev\`, so the token file exists when the config is read. Until it does the token is empty and the page reloads once the daemon is up.`;
144
+ }
145
+ /**
146
+ * The app-side dev module the Vite plugin imports by convention.
147
+ *
148
+ * `registerCapabilities` tells the agent what it can drive without guessing; `registerStore` is the
149
+ * one that matters most and the one we cannot write for you — detecting that an app depends on
150
+ * zustand is easy, knowing which module exports the store instance is not, and a wrong import here
151
+ * breaks the module everything else hangs off. So the store lines are generated COMMENTED, naming
152
+ * the libraries actually found in package.json, with the exact call to uncomment.
153
+ */
154
+ export function viteDevModuleFile(testids, stores) {
155
+ const ids = testids.map((t) => `'${t}'`).join(', ');
156
+ const storeBlock = stores.length === 0
157
+ ? ' // No state library detected. If you add one, register it here — see docs/usage.md.'
158
+ : stores.map((h) => ` // import your store, then: ${h}`).join('\n');
159
+ return `// Dev-only. Imported automatically by @reticlehq/vite-plugin — you do not need to import it.
160
+ // Self-guards on import.meta.env.DEV, so it is a no-op in a production build.
161
+ import { registerCapabilities } from '@reticlehq/react';
162
+
163
+ if (import.meta.env.DEV) {
164
+ // ── Start with ONE flow. ─────────────────────────────────────────────────────────────────────
165
+ // You do not need to describe the whole app to get value, and trying to is the slow path. Register
166
+ // the store your most important flow reads, and list the testids that flow touches. Add more later,
167
+ // when a flow you actually replay needs them.
168
+ //
169
+ // Registering a store is the highest-value line in this file: it lets the agent check what the app
170
+ // BELIEVES, not just what it rendered — the class of bug a screenshot cannot see. Pass the STORE,
171
+ // not \`() => store.getState()\`: the store form wires \`subscribe\` too, so every mutation emits a
172
+ // state diff; the getter form is read-only and silently produces empty diffs.
173
+ ${storeBlock}
174
+
175
+ registerCapabilities({
176
+ testids: [${ids}],${testids.length === 0 ? ' // none found — add data-testid to your key elements' : ''}
177
+ signals: [], // names you pass to reticle.signal()
178
+ stores: [], // the keys you registered above
179
+ });
180
+ }
181
+ `;
182
+ }
183
+ /** Where that module goes. Matches @reticlehq/vite-plugin's convention list. */
184
+ export const VITE_DEV_MODULE_PATH = 'src/reticle-dev.ts';
185
+ /** Default root-layout path, used when no layout was found on disk (reporting only). */
186
+ export const NEXT_LAYOUT_PATH = 'app/layout.tsx';
58
187
  /** Mount instruction for the root layout. */
59
188
  export const NEXT_LAYOUT_MANUAL = `Mount <ReticleDev /> in your root layout (app/layout.tsx), dev-only:
60
189
 
@@ -90,21 +219,38 @@ export const SVELTEKIT_HOOKS_PATH = 'src/hooks.client.ts';
90
219
  * Said to the user's face rather than discovered later. React, Next, Remix and Astro each have an
91
220
  * app and a CI gate; SvelteKit has neither, so "it generated some wiring" is not evidence it works.
92
221
  */
93
- export const UNVERIFIED_FRAMEWORK_NOTE = 'Reticle has no SvelteKit app and no CI gate for one, so this wiring is untested — it may work, but nothing proves it and nothing will tell us if it breaks. Supported and gated today: Vite + React, Next.js, Remix, Astro, and plain HTML. If the hook does not register a session, please open an issue.';
222
+ /**
223
+ * Said out loud when the app does not render through React. The SDK is framework-agnostic — DOM,
224
+ * network, console and routing all still work — but `@reticlehq/react` is a React adapter, so
225
+ * component names and source mapping do not, and no CI gate covers this stack. Reporting all-green
226
+ * here is the one thing this project exists not to do.
227
+ */
228
+ export function unverifiedUiLibraryNote(library) {
229
+ return `Detected a ${library} app. Reticle's DOM, network, console and state tools work here, but @reticlehq/react is a React adapter: component names and data-reticle-source file:line will be missing, and no CI gate covers ${library}. Gated today: Vite + React, Next.js, Remix, Astro. If something doesn't work, please open an issue.`;
230
+ }
231
+ export const UNVERIFIED_FRAMEWORK_NOTE = 'Reticle has no SvelteKit app and no CI gate for one, so this wiring is untested — it may work, but nothing proves it and nothing will tell us if it breaks. Supported and gated today: Vite + React, Next.js, Remix and Astro. If the hook does not register a session, please open an issue.';
94
232
  /**
95
233
  * Dev-only client hook that connects Reticle in a SvelteKit app. SvelteKit renders through app.html and
96
234
  * never triggers Vite's index.html injection (verified), so the standard plugin can't auto-connect —
97
235
  * a client hook is the reliable path. SvelteKit runs src/hooks.client.ts on the client at startup.
98
236
  */
99
237
  export function svelteKitHooksFile(port, projectId) {
238
+ const base = connectArg(port, projectId);
239
+ const fields = base === '' ? '' : `${base.slice(1, -1).trim()}, `;
100
240
  return `// Dev-only: connect Reticle on the client. SvelteKit renders via app.html, so the Vite-plugin
101
241
  // index.html injection doesn't fire — connect from this client hook instead.
102
242
  if (import.meta.env.DEV) {
103
243
  void import('@reticlehq/react').then(({ reticle, install }) => {
104
244
  install();
105
- reticle.connect(${connectArg(port, projectId)});
245
+ // The bridge requires the pairing token even on localhost. Nothing in a browser can read the
246
+ // file it lives in, so @reticlehq/vite-plugin inlines it here at build time. Without it the
247
+ // console reads "bridge refused the connection: authentication failed" and no session appears.
248
+ const token = typeof __RETICLE_TOKEN__ !== 'undefined' ? __RETICLE_TOKEN__ : '';
249
+ reticle.connect({ ${fields}...(token.length > 0 ? { token } : {}) });
106
250
  });
107
251
  }
252
+
253
+ declare const __RETICLE_TOKEN__: string | undefined;
108
254
  `;
109
255
  }
110
256
  /**
@@ -4,21 +4,15 @@
4
4
  * bails to a `manual` result so we never half-edit a build config (a broken config is worse than a
5
5
  * documented manual step).
6
6
  */
7
+ import { PatchKind, type SourcePatch } from './patch-kind.js';
7
8
  export declare const VITE_IMPORT = "import { reticle } from '@reticlehq/vite-plugin';";
9
+ /** Alias kept so existing call sites read in Vite terms; the vocabulary is shared (see patch-kind). */
8
10
  export declare const VitePatchKind: {
9
11
  readonly APPLY: "apply";
10
12
  readonly ALREADY: "already";
11
13
  readonly MANUAL: "manual";
12
14
  };
13
- export type VitePatchKind = (typeof VitePatchKind)[keyof typeof VitePatchKind];
14
- type VitePatch = {
15
- kind: typeof VitePatchKind.APPLY;
16
- code: string;
17
- } | {
18
- kind: typeof VitePatchKind.ALREADY;
19
- } | {
20
- kind: typeof VitePatchKind.MANUAL;
21
- reason: string;
22
- };
15
+ export type VitePatchKind = PatchKind;
16
+ type VitePatch = SourcePatch;
23
17
  export declare function patchViteConfig(source: string, port?: number): VitePatch;
24
18
  export {};
@@ -4,6 +4,7 @@
4
4
  * bails to a `manual` result so we never half-edit a build config (a broken config is worse than a
5
5
  * documented manual step).
6
6
  */
7
+ import { PatchKind } from './patch-kind.js';
7
8
  export const VITE_IMPORT = "import { reticle } from '@reticlehq/vite-plugin';";
8
9
  const RETICLE_MARKER = '@reticlehq/vite-plugin';
9
10
  /** The `reticle...)` call — carries the bridge port so the injected connect targets it. */
@@ -14,11 +15,8 @@ function reticlePluginCall(port) {
14
15
  const PLUGINS_ARRAY = /plugins\s*:\s*\[/;
15
16
  /** Matches an ES import statement (used to place our import after the last one). */
16
17
  const IMPORT_LINE = /^import\s.+from\s+['"][^'"]+['"];?\s*$/gm;
17
- export const VitePatchKind = {
18
- APPLY: 'apply',
19
- ALREADY: 'already',
20
- MANUAL: 'manual',
21
- };
18
+ /** Alias kept so existing call sites read in Vite terms; the vocabulary is shared (see patch-kind). */
19
+ export const VitePatchKind = PatchKind;
22
20
  const NO_PLUGINS_REASON = "couldn't find a `plugins: [...]` array to extend";
23
21
  function insertImport(source) {
24
22
  const matches = [...source.matchAll(IMPORT_LINE)];
@@ -29,9 +27,19 @@ function insertImport(source) {
29
27
  const end = last.index + last[0].length;
30
28
  return `${source.slice(0, end)}\n${VITE_IMPORT}${source.slice(end)}`;
31
29
  }
30
+ /**
31
+ * Insert right after the opening `[` of the plugins array, spaced the way the surrounding line is.
32
+ *
33
+ * A multi-line array puts a newline next, and `[reticle(), \n` leaves trailing whitespace — exactly
34
+ * what a formatter rewrites, turning a one-line install into a diff against the user's own style. A
35
+ * single-line array needs the space, or the result reads `[reticle(),react()]`.
36
+ */
32
37
  function insertPlugin(source, port) {
33
- // Insert right after the opening `[` of the plugins array.
34
- return source.replace(PLUGINS_ARRAY, (match) => `${match}${reticlePluginCall(port)}, `);
38
+ return source.replace(PLUGINS_ARRAY, (match, _g, offset) => {
39
+ const next = source[offset + match.length] ?? '';
40
+ const separator = next === '' || /\s/.test(next) ? '' : ' ';
41
+ return `${match}${reticlePluginCall(port)},${separator}`;
42
+ });
35
43
  }
36
44
  export function patchViteConfig(source, port) {
37
45
  if (source.includes(RETICLE_MARKER)) {
@@ -0,0 +1,15 @@
1
+ import { type RedactionPolicy } from '@reticlehq/core';
2
+ /** Record what a session declared sensitive. Called when its hello lands. */
3
+ export declare function declareDrivenRedactionKeys(sessionId: string, keys: readonly string[]): void;
4
+ /** Drop a session's declarations when it disconnects, so a closed tab stops widening the union. */
5
+ export declare function forgetDrivenRedactionKeys(sessionId: string): void;
6
+ /** Every key declared by any connected session, sorted so the memo signature is stable. */
7
+ export declare function drivenRedactionKeys(): string[];
8
+ /**
9
+ * The rule in force for driven-path captures: the built-in rule plus every declared key. Identical
10
+ * to the built-in rule when no session declared anything, which is the overwhelmingly common case
11
+ * and the one that must stay byte-identical to before this feature existed.
12
+ */
13
+ export declare function drivenRedactionPolicy(): RedactionPolicy;
14
+ /** Test seam: drop every declaration. Not used in production — sessions unregister themselves. */
15
+ export declare function resetDrivenRedaction(): void;
@@ -0,0 +1,76 @@
1
+ import { buildRedactionPolicy } from '@reticlehq/core';
2
+ /**
3
+ * The redaction rule the DRIVEN path uses — the one place in the daemon that redacts a payload the
4
+ * browser SDK never saw.
5
+ *
6
+ * ## Why this exists at all
7
+ *
8
+ * `connect({ redact: { keys } })` is configured in the page, and the SDK redacts everything it
9
+ * observes. But a driven session's request bodies and response headers are captured by the daemon
10
+ * straight from the network stack (`page.on('response')`), so they never pass through the SDK. An app
11
+ * that declared `licenceKey` sensitive would see it redacted everywhere the developer looked, and
12
+ * find it in cleartext in the on-disk journal — a leak on precisely the path they cannot inspect.
13
+ * So a session's declared keys are announced in its hello and land here.
14
+ *
15
+ * ## Why a daemon-wide UNION rather than a per-session rule
16
+ *
17
+ * A NET_DETAIL is redacted at capture time and only routed to a session afterwards, by `pageUrl`.
18
+ * At the moment of redaction there is no session in hand, and there is a real window in which there
19
+ * is no session at all yet. Resolving one first would mean either delaying redaction (holding a raw
20
+ * credential in memory until routing succeeds) or dropping the detail.
21
+ *
22
+ * The union sidesteps that, and is only safe because of an asymmetry that is enforced upstream:
23
+ * declared keys can ONLY add redaction. The worst case is that one local app's declared key
24
+ * over-redacts a same-named field in another local app on the same daemon — an inconvenience, in the
25
+ * safe direction, between two apps belonging to the same developer on the same machine. The
26
+ * dangerous direction is closed by construction: `allow` never crosses the bridge, so nothing a page
27
+ * sends can lower this floor below the built-in rule.
28
+ */
29
+ /** Declared keys per session id, so a disconnect narrows the union back down. */
30
+ const declaredBySession = new Map();
31
+ /** Memoized policy + the key set it was built from, so the hot path does not rebuild per response. */
32
+ let cached;
33
+ function invalidate() {
34
+ cached = undefined;
35
+ }
36
+ /** Record what a session declared sensitive. Called when its hello lands. */
37
+ export function declareDrivenRedactionKeys(sessionId, keys) {
38
+ if (keys.length === 0) {
39
+ if (declaredBySession.delete(sessionId))
40
+ invalidate();
41
+ return;
42
+ }
43
+ declaredBySession.set(sessionId, [...keys]);
44
+ invalidate();
45
+ }
46
+ /** Drop a session's declarations when it disconnects, so a closed tab stops widening the union. */
47
+ export function forgetDrivenRedactionKeys(sessionId) {
48
+ if (declaredBySession.delete(sessionId))
49
+ invalidate();
50
+ }
51
+ /** Every key declared by any connected session, sorted so the memo signature is stable. */
52
+ export function drivenRedactionKeys() {
53
+ const all = new Set();
54
+ for (const keys of declaredBySession.values())
55
+ for (const key of keys)
56
+ all.add(key);
57
+ return [...all].sort();
58
+ }
59
+ /**
60
+ * The rule in force for driven-path captures: the built-in rule plus every declared key. Identical
61
+ * to the built-in rule when no session declared anything, which is the overwhelmingly common case
62
+ * and the one that must stay byte-identical to before this feature existed.
63
+ */
64
+ export function drivenRedactionPolicy() {
65
+ const keys = drivenRedactionKeys();
66
+ const signature = keys.join('\u0000');
67
+ if (cached?.signature !== signature) {
68
+ cached = { signature, policy: buildRedactionPolicy({ keys }) };
69
+ }
70
+ return cached.policy;
71
+ }
72
+ /** Test seam: drop every declaration. Not used in production — sessions unregister themselves. */
73
+ export function resetDrivenRedaction() {
74
+ declaredBySession.clear();
75
+ invalidate();
76
+ }
@@ -1,4 +1,4 @@
1
- import { type ReticleEvent } from '@reticlehq/core';
1
+ import { type RedactionPolicy, type ReticleEvent } from '@reticlehq/core';
2
2
  /**
3
3
  * CDP-authoritative network detail. Wherever the daemon has a CDP connection — whether it launched
4
4
  * the browser or attached to one someone else started — `page.on('response')` sees the FULL response:
@@ -34,7 +34,13 @@ export interface NetworkDetail {
34
34
  */
35
35
  pageUrl?: string;
36
36
  }
37
- /** Shape a raw authoritative response into a NET_DETAIL payload (lower-cased headers). */
37
+ /**
38
+ * Shape a raw authoritative response into a NET_DETAIL payload (lower-cased headers).
39
+ *
40
+ * `policy` defaults to the built-in rule so this stays a PURE builder — the driven-path union is
41
+ * resolved by `attachNetworkDetail`, at the glue layer, where reading process state is not a lie
42
+ * about the function's inputs.
43
+ */
38
44
  export declare function buildNetworkDetail(raw: {
39
45
  url: string;
40
46
  method?: string;
@@ -43,7 +49,7 @@ export declare function buildNetworkDetail(raw: {
43
49
  resourceType?: string;
44
50
  requestBody?: string;
45
51
  pageUrl?: string;
46
- }): NetworkDetail;
52
+ }, policy?: RedactionPolicy): NetworkDetail;
47
53
  /**
48
54
  * Fold each NET_DETAIL onto the matching in-page NET_REQUEST (by method+url), enriching it with the
49
55
  * authoritative headers/resourceType the page-side wrapper couldn't see — WITHOUT clobbering fields the
@@ -1,6 +1,16 @@
1
- import { EventType, REDACTED_VALUE, isSensitiveKey, scrubKnownSecrets, } from '@reticlehq/core';
1
+ import { EventType, REDACTED_VALUE, defaultIsSensitiveKey, scrubKnownSecrets, } from '@reticlehq/core';
2
+ import { drivenRedactionPolicy } from './driven-redaction.js';
2
3
  /** Bound on a captured wire body — matches the in-page capture so the two are comparable. */
3
4
  const MAX_WIRE_BODY_CHARS = 8192;
5
+ /**
6
+ * The rule this module redacts by.
7
+ *
8
+ * `defaultIsSensitiveKey`, never the ambient `isSensitiveKey`: a daemon serves many sessions in one
9
+ * process, so an ambient policy here would let one app's config decide another app's redaction.
10
+ * Callers that have a session's declarations pass a policy explicitly; everything else gets the
11
+ * built-in floor, which is exactly what this module did before the rule became configurable.
12
+ */
13
+ const DEFAULT_POLICY = { isSensitiveKey: defaultIsSensitiveKey };
4
14
  /**
5
15
  * Redact a sensitive key's value whatever its TYPE — string, number, array, or a nested object.
6
16
  *
@@ -8,19 +18,19 @@ const MAX_WIRE_BODY_CHARS = 8192;
8
18
  * SDK's sanitizer (Playwright hands it over raw), so its redaction has to be as strong as the SDK's,
9
19
  * which is key-based over a parsed object — not string-shaped.
10
20
  */
11
- function redactByKey(value) {
21
+ function redactByKey(value, policy) {
12
22
  if (Array.isArray(value))
13
- return value.map(redactByKey);
23
+ return value.map((v) => redactByKey(v, policy));
14
24
  if (value !== null && typeof value === 'object') {
15
25
  const out = {};
16
26
  for (const [k, v] of Object.entries(value)) {
17
- out[k] = isSensitiveKey(k) ? REDACTED_VALUE : redactByKey(v);
27
+ out[k] = policy.isSensitiveKey(k) ? REDACTED_VALUE : redactByKey(v, policy);
18
28
  }
19
29
  return out;
20
30
  }
21
31
  return value;
22
32
  }
23
- function projectWireBody(raw) {
33
+ function projectWireBody(raw, policy) {
24
34
  const bounded = raw.length > MAX_WIRE_BODY_CHARS ? raw.slice(0, MAX_WIRE_BODY_CHARS) : raw;
25
35
  const byShape = scrubKnownSecrets(bounded);
26
36
  // Prefer a STRUCTURAL pass. A sensitive key must be redacted regardless of its value type — a
@@ -29,7 +39,7 @@ function projectWireBody(raw) {
29
39
  // straight to the agent's context and the on-disk journal. Parsing and walking redacts them by key
30
40
  // whatever the shape.
31
41
  try {
32
- return JSON.stringify(redactByKey(JSON.parse(byShape)));
42
+ return JSON.stringify(redactByKey(JSON.parse(byShape), policy));
33
43
  }
34
44
  catch {
35
45
  // Not JSON (a truncated capture, or a form-encoded body — the shape a login form actually POSTs).
@@ -37,8 +47,8 @@ function projectWireBody(raw) {
37
47
  // truncated body still contains, and the `key=value` pair of `application/x-www-form-urlencoded`,
38
48
  // which neither the JSON path nor the old regex ever touched — so `password=hunter2` leaked.
39
49
  return byShape
40
- .replace(/"([^"]+)"\s*:\s*"([^"]*)"/g, (whole, key) => isSensitiveKey(key) ? `"${key}":"${REDACTED_VALUE}"` : whole)
41
- .replace(/([^&?=\s]+)=([^&\s]*)/g, (whole, key) => isSensitiveKey(key) ? `${key}=${REDACTED_VALUE}` : whole);
50
+ .replace(/"([^"]+)"\s*:\s*"([^"]*)"/g, (whole, key) => policy.isSensitiveKey(key) ? `"${key}":"${REDACTED_VALUE}"` : whole)
51
+ .replace(/([^&?=\s]+)=([^&\s]*)/g, (whole, key) => policy.isSensitiveKey(key) ? `${key}=${REDACTED_VALUE}` : whole);
42
52
  }
43
53
  }
44
54
  /**
@@ -50,25 +60,31 @@ function projectWireBody(raw) {
50
60
  * credential HEADER is redacted whole by key; any other header value is still swept for known secret
51
61
  * SHAPES (a JWT echoed in a custom header), matching how request bodies are handled.
52
62
  */
53
- function projectHeaders(headers) {
63
+ function projectHeaders(headers, policy) {
54
64
  const out = {};
55
65
  for (const [k, v] of Object.entries(headers)) {
56
66
  const key = k.toLowerCase();
57
- out[key] = isSensitiveKey(key) ? REDACTED_VALUE : scrubKnownSecrets(v);
67
+ out[key] = policy.isSensitiveKey(key) ? REDACTED_VALUE : scrubKnownSecrets(v);
58
68
  }
59
69
  return out;
60
70
  }
61
- /** Shape a raw authoritative response into a NET_DETAIL payload (lower-cased headers). */
62
- export function buildNetworkDetail(raw) {
71
+ /**
72
+ * Shape a raw authoritative response into a NET_DETAIL payload (lower-cased headers).
73
+ *
74
+ * `policy` defaults to the built-in rule so this stays a PURE builder — the driven-path union is
75
+ * resolved by `attachNetworkDetail`, at the glue layer, where reading process state is not a lie
76
+ * about the function's inputs.
77
+ */
78
+ export function buildNetworkDetail(raw, policy = DEFAULT_POLICY) {
63
79
  return {
64
80
  url: raw.url,
65
81
  ...(raw.method === undefined ? {} : { method: raw.method }),
66
82
  status: raw.status,
67
- headers: projectHeaders(raw.headers),
83
+ headers: projectHeaders(raw.headers, policy),
68
84
  ...(raw.resourceType === undefined ? {} : { resourceType: raw.resourceType }),
69
85
  ...(raw.requestBody === undefined || raw.requestBody.length === 0
70
86
  ? {}
71
- : { requestBody: projectWireBody(raw.requestBody) }),
87
+ : { requestBody: projectWireBody(raw.requestBody, policy) }),
72
88
  ...(raw.pageUrl === undefined || raw.pageUrl.length === 0 ? {} : { pageUrl: raw.pageUrl }),
73
89
  };
74
90
  }
@@ -148,7 +164,11 @@ export function attachNetworkDetail(page, emit) {
148
164
  ...(resourceType === undefined ? {} : { resourceType }),
149
165
  ...(postData === null ? {} : { requestBody: postData }),
150
166
  pageUrl: page.url(),
151
- }));
167
+ },
168
+ // Resolved per response, not per attachment: a session declaring extra keys can connect
169
+ // after the listener is attached, and capturing the policy once would leave every
170
+ // already-driven page redacting by the rule that was in force before the app said so.
171
+ drivenRedactionPolicy()));
152
172
  });
153
173
  // `response.headers()` REJECTS when the page/CDP session is closing — precisely when responses race
154
174
  // teardown (navigation, tab close). Without this catch the rejection floats; on the daemon path a
@@ -1,3 +1,32 @@
1
+ /**
2
+ * How many consecutive failed reconnects before the proxy gives up and exits. At the capped backoff
3
+ * this is a few minutes — long enough to ride out a daemon restart, short enough that a genuinely
4
+ * dead daemon lets the agent host respawn the proxy (which spawns a fresh daemon) instead of hanging.
5
+ */
6
+ export declare const MAX_RECONNECT_ATTEMPTS = 60;
7
+ export declare function reconnectDelayMs(attempt: number): number;
8
+ /** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
9
+ export declare const RECONNECT_INITIALIZE_ID = "__reticle_proxy_reinit";
10
+ /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
11
+ export declare function proxyLogPath(): string;
12
+ /**
13
+ * Makes a reconnect invisible to the client.
14
+ *
15
+ * The daemon builds a FRESH `McpServer` per SSE connection, so a reconnected session has never seen
16
+ * the client's `initialize` — every subsequent `tools/call` would hit an uninitialized server. The
17
+ * proxy therefore remembers the handshake the client sent once and replays it into each new session.
18
+ * The replay is re-issued under a reserved id so the daemon's response to it can be dropped rather
19
+ * than reaching the client's stdout, where a duplicate JSON-RPC id would be a protocol violation.
20
+ */
21
+ export declare class HandshakeReplay {
22
+ #private;
23
+ /** Record one outbound client line. Cheap enough to call for every stdin message. */
24
+ observeOutbound(line: string): void;
25
+ /** Lines to POST into a freshly-established session, before any queued client traffic. */
26
+ replayLines(): string[];
27
+ /** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
28
+ shouldSuppressInbound(line: string): boolean;
29
+ }
1
30
  /** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
2
31
  export interface SseFrame {
3
32
  event: string;
@@ -28,8 +57,11 @@ export declare function waitForDaemon(port: number): Promise<void>;
28
57
  export declare function buildSessionUrl(rawData: string, port: number): string;
29
58
  /**
30
59
  * Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
31
- * Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until
32
- * stdin closes or the SSE stream ends (at which point the process exits so
33
- * Claude Code restarts the proxy fresh).
60
+ * Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
61
+ *
62
+ * A dropped SSE stream used to exit the process, which is what made the agent's `reticle_*` tools
63
+ * vanish mid-session with no message and no way for the agent to get them back — only a human running
64
+ * `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
65
+ * transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
34
66
  */
35
67
  export declare function startMcpProxy(port: number): Promise<never>;