@reticlehq/server 2.9.0 → 2.11.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 (125) hide show
  1. package/SKILL.md +64 -26
  2. package/dist/capsule/capsule-store.d.ts +2 -2
  3. package/dist/cli/cli-parse.d.ts +1 -1
  4. package/dist/cli/cli-parse.js +1 -1
  5. package/dist/cli/cli-verify.js +1 -1
  6. package/dist/crawl/crawl.d.ts +7 -2
  7. package/dist/crawl/crawl.js +52 -9
  8. package/dist/dev/stale-issue-guard.d.ts +43 -0
  9. package/dist/dev/stale-issue-guard.js +75 -0
  10. package/dist/domain/domain-tools.js +3 -2
  11. package/dist/ee/audit-log.js +2 -2
  12. package/dist/events/body-failures.d.ts +2 -2
  13. package/dist/events/contradiction-folds.d.ts +45 -0
  14. package/dist/events/contradiction-folds.js +42 -0
  15. package/dist/events/contradictions.d.ts +19 -1
  16. package/dist/events/contradictions.js +17 -2
  17. package/dist/events/echo-mismatch.d.ts +2 -2
  18. package/dist/events/predicate-asks.d.ts +17 -0
  19. package/dist/events/predicate-asks.js +26 -0
  20. package/dist/events/predicate-eval.js +211 -24
  21. package/dist/events/predicate-parse.js +9 -3
  22. package/dist/events/predicate-schema.d.ts +18 -0
  23. package/dist/events/predicate-schema.js +55 -0
  24. package/dist/events/stale-response.d.ts +2 -3
  25. package/dist/events/stale-response.js +49 -2
  26. package/dist/events/unit-mismatch.d.ts +2 -2
  27. package/dist/flows/flow-tools.js +34 -5
  28. package/dist/flows/predicate-to-expect.js +8 -0
  29. package/dist/flows/verify-change-tools.js +6 -2
  30. package/dist/honesty/blind-spots.d.ts +9 -0
  31. package/dist/honesty/blind-spots.js +17 -4
  32. package/dist/honesty/gap-ledger.d.ts +52 -0
  33. package/dist/honesty/gap-ledger.js +63 -0
  34. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  35. package/dist/honesty/instrumentation-gaps.js +21 -0
  36. package/dist/honesty/observability.d.ts +37 -0
  37. package/dist/honesty/observability.js +40 -0
  38. package/dist/honesty/verified.js +16 -0
  39. package/dist/index.d.ts +18 -1
  40. package/dist/index.js +57 -0
  41. package/dist/init/mcp.d.ts +1 -2
  42. package/dist/init/mcp.js +4 -1
  43. package/dist/init/node-io.js +3 -0
  44. package/dist/init/run.d.ts +18 -0
  45. package/dist/init/run.js +34 -3
  46. package/dist/init/slash-command.d.ts +1 -1
  47. package/dist/init/slash-command.js +33 -10
  48. package/dist/journal/envelope.d.ts +24 -24
  49. package/dist/license/license.d.ts +76 -5
  50. package/dist/license/license.js +93 -29
  51. package/dist/mcp/mcp-proxy.d.ts +2 -27
  52. package/dist/mcp/mcp-proxy.js +4 -88
  53. package/dist/mcp/mcp.d.ts +18 -2
  54. package/dist/mcp/mcp.js +31 -10
  55. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  56. package/dist/mcp/proxy-daemon-probe.js +48 -0
  57. package/dist/mcp/proxy-handshake.js +1 -1
  58. package/dist/mcp/server-instructions.js +2 -2
  59. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  60. package/dist/mcp/sse-frame-parser.js +41 -0
  61. package/dist/project/artifact-root.d.ts +83 -0
  62. package/dist/project/artifact-root.js +112 -0
  63. package/dist/project/memory-fs.d.ts +9 -0
  64. package/dist/project/memory-fs.js +88 -0
  65. package/dist/project/project-store.d.ts +17 -0
  66. package/dist/project/project-store.js +33 -0
  67. package/dist/project/remember-project.d.ts +24 -0
  68. package/dist/project/remember-project.js +40 -0
  69. package/dist/project/session-root.d.ts +15 -0
  70. package/dist/project/session-root.js +31 -0
  71. package/dist/runs/run-tools.js +2 -1
  72. package/dist/session/lease-availability.d.ts +40 -0
  73. package/dist/session/lease-availability.js +43 -0
  74. package/dist/session/no-session-diagnosis.d.ts +17 -0
  75. package/dist/session/no-session-diagnosis.js +41 -8
  76. package/dist/session/no-session-watch.js +7 -0
  77. package/dist/session/session-readiness.js +1 -1
  78. package/dist/session/session.d.ts +9 -0
  79. package/dist/session/session.js +9 -0
  80. package/dist/session/stall-clock.d.ts +18 -0
  81. package/dist/session/stall-clock.js +48 -0
  82. package/dist/telemetry/daemon-telemetry.js +16 -22
  83. package/dist/telemetry/license-activation.d.ts +48 -0
  84. package/dist/telemetry/license-activation.js +57 -0
  85. package/dist/telemetry/session-metrics.d.ts +1 -1
  86. package/dist/telemetry/session-metrics.js +1 -1
  87. package/dist/telemetry/telemetry.d.ts +12 -3
  88. package/dist/telemetry/telemetry.js +34 -5
  89. package/dist/tools/act-tools.js +34 -4
  90. package/dist/tools/assert-verdict.d.ts +2 -0
  91. package/dist/tools/assert-verdict.js +24 -1
  92. package/dist/tools/contract-tools.js +5 -3
  93. package/dist/tools/coverage-tools.js +33 -0
  94. package/dist/tools/dynamic-tools.js +1 -1
  95. package/dist/tools/error-recovery.js +1 -1
  96. package/dist/tools/feedback-tools.js +31 -6
  97. package/dist/tools/invoke-tool.d.ts +1 -1
  98. package/dist/tools/invoke-tool.js +10 -5
  99. package/dist/tools/observe-tools.js +8 -2
  100. package/dist/tools/tool-kit.d.ts +46 -4
  101. package/dist/tools/tool-names.d.ts +2 -0
  102. package/dist/tools/tool-names.js +2 -0
  103. package/dist/tools/tool-surface.js +2 -3
  104. package/dist/tools/tools.js +16 -1
  105. package/docs/README.md +1 -0
  106. package/docs/agent-cheatsheet.md +2 -2
  107. package/docs/capabilities.mdx +4 -4
  108. package/docs/cli/hunt.mdx +2 -2
  109. package/docs/deploy-checks.md +2 -2
  110. package/docs/docs.json +2 -2
  111. package/docs/enterprise.md +9 -3
  112. package/docs/flows.md +4 -4
  113. package/docs/gates.md +1 -1
  114. package/docs/license-activation.mdx +145 -0
  115. package/docs/recipes.mdx +2 -2
  116. package/docs/telemetry-contract.md +18 -12
  117. package/docs/telemetry-events.mdx +1 -34
  118. package/docs/telemetry.md +0 -1
  119. package/docs/tools-overview.mdx +1 -1
  120. package/docs/tools-session-and-feedback.mdx +5 -0
  121. package/docs/tools-tools-and-run.mdx +15 -15
  122. package/docs/usage.md +3 -3
  123. package/package.json +2 -2
  124. package/dist/telemetry/instrumentation-stall.d.ts +0 -50
  125. package/dist/telemetry/instrumentation-stall.js +0 -90
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
14
14
  import { z } from 'zod';
15
+ import { LicenseActivation } from '@reticlehq/core';
15
16
  /** Outcome of verifying a license key. */
16
17
  export const LicenseStatus = {
17
18
  VALID: 'valid',
@@ -20,8 +21,19 @@ export const LicenseStatus = {
20
21
  BAD_SIGNATURE: 'bad-signature',
21
22
  EXPIRED: 'expired',
22
23
  };
23
- /** The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features unlock. */
24
+ /**
25
+ * The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features
26
+ * unlock.
27
+ *
28
+ * `lid` is the STABLE LICENSE ID and the only safe join key for per-customer usage: `org` is a display
29
+ * name a human typed at signing time, so two customers called "Acme" merge into one and a customer who
30
+ * renames splits into two. Every key carries one (minted by the issuer, `scripts/issue-license.mjs`),
31
+ * which is why it is required rather than optional — an optional id is one that goes missing on exactly
32
+ * the keys somebody later needs to attribute, and a key without it should be re-issued, not silently
33
+ * counted as nobody.
34
+ */
24
35
  const LicensePayloadSchema = z.object({
36
+ lid: z.string().min(1),
25
37
  org: z.string(),
26
38
  plan: z.string(),
27
39
  exp: z.number(),
@@ -68,40 +80,52 @@ export function verifyLicenseKey(key, publicKey, now) {
68
80
  if (!signatureOk)
69
81
  return { status: LicenseStatus.BAD_SIGNATURE };
70
82
  if (payload.exp <= now)
71
- return { status: LicenseStatus.EXPIRED };
83
+ return { status: LicenseStatus.EXPIRED, payload };
72
84
  return { status: LicenseStatus.VALID, payload };
73
85
  }
86
+ /**
87
+ * Where to get a key. A named constant because it appears in the gate's error AND in what
88
+ * `reticle license` prints, and a contact address that drifts between the two is a support request
89
+ * that reaches nobody.
90
+ */
91
+ export const LICENSE_CONTACT = 'hey@reticle.sh';
92
+ /**
93
+ * The features a licence gates in THIS build. The single source: `assertEnterprise` callers name a
94
+ * member rather than a string literal, and `reticle license` reports the list, so what the CLI claims
95
+ * is gated is derived from what is actually gated rather than a second list somebody maintains.
96
+ *
97
+ * It is deliberately short. Reticle's roadmap for enterprise is longer (SSO, SCIM, RBAC, multi-org,
98
+ * policy gates), and none of it is here yet — printing a roadmap as though it were shipped is how a
99
+ * buyer discovers on day two that the thing they paid for does not exist. What is listed is what the
100
+ * running build will actually refuse without a key.
101
+ */
102
+ export const EnterpriseFeature = {
103
+ /** Recording an audit event. */
104
+ AUDIT_LOG: 'audit-log',
105
+ };
74
106
  /** Thrown when a production-mode enterprise feature is used without a valid license. */
75
107
  export class EnterpriseLicenseError extends Error {
76
108
  feature;
77
109
  reason;
78
110
  constructor(feature, reason) {
79
- super(`Reticle Enterprise feature "${feature}" requires a valid license (${reason}). Contact hey@reticle.sh.`);
111
+ super(`Reticle Enterprise feature "${feature}" requires a valid license (${reason}). Contact ${LICENSE_CONTACT}.`);
80
112
  this.name = 'EnterpriseLicenseError';
81
113
  this.feature = feature;
82
114
  this.reason = reason;
83
115
  }
84
116
  }
85
- /** The issuer public key from the environment (set at release / by the operator); undefined if unset. */
86
- function issuerPublicKey() {
87
- const pem = process.env[LICENSE_PUBLIC_KEY_ENV];
88
- if (pem === undefined || 0 === pem.length)
89
- return undefined;
90
- try {
91
- return createPublicKey(pem);
92
- }
93
- catch {
94
- return undefined;
95
- }
96
- }
97
117
  /**
98
118
  * Gate an enterprise feature. No-op in dev/eval (requireLicense:false). In production it throws
99
119
  * EnterpriseLicenseError unless a valid, unexpired key that covers `feature` is present.
100
120
  */
101
- export function assertEnterprise(feature, ctx) {
121
+ export function assertEnterprise(feature, ctx, env = process.env, baked) {
102
122
  if (!ctx.requireLicense)
103
123
  return;
104
- const publicKey = ctx.publicKey ?? issuerPublicKey();
124
+ // Resolved exactly as the env-entry point resolves it: BAKED first, env only as the dev/test
125
+ // hatch. This path used to read the environment alone, so on a real release, where the key is
126
+ // baked and no env var is set, it denied every valid licence with `no-issuer-key` while
127
+ // assertEnterpriseFromEnv allowed the same key on the same build. Two gates, one truth.
128
+ const publicKey = ctx.publicKey ?? loadPublicKey(resolveIssuerPublicKeyPem(env, baked));
105
129
  if (publicKey === undefined)
106
130
  throw new EnterpriseLicenseError(feature, 'no-issuer-key');
107
131
  const check = verifyLicenseKey(ctx.key, publicKey, ctx.now());
@@ -122,7 +146,7 @@ export const LICENSE_PUBLIC_KEY_ENV = 'RETICLE_LICENSE_PUBLIC_KEY';
122
146
  * never setting RETICLE_LICENSE_PUBLIC_KEY, because the baked key takes precedence. The public key is
123
147
  * safe to ship openly — it can only verify licenses, never mint them (that needs the private key).
124
148
  */
125
- const BAKED_ISSUER_PUBLIC_KEY_PEM = '';
149
+ const BAKED_ISSUER_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAcg+u61Fs5pwDHcYxFGhaxVaQoUbAdyl+RUdSViZyxDY=\n-----END PUBLIC KEY-----";
126
150
  /**
127
151
  * Resolve the issuer public-key PEM enforcement uses: the baked-in release key wins, so it can't be
128
152
  * turned off from the environment. Only when nothing is baked (dev/repo) does the env var apply — that
@@ -134,6 +158,15 @@ function resolveIssuerPublicKeyPem(env, baked = BAKED_ISSUER_PUBLIC_KEY_PEM) {
134
158
  const envPem = env[LICENSE_PUBLIC_KEY_ENV];
135
159
  return envPem !== undefined && envPem.length > 0 ? envPem : undefined;
136
160
  }
161
+ /**
162
+ * The two fields every report carries, spread into each branch. One object rather than a field on each
163
+ * return, because the value that must never go missing is the one on the branch nobody was thinking
164
+ * about — and the branch an unlicensed reader lands on is exactly that branch.
165
+ */
166
+ const LICENSE_OFFER = {
167
+ gated: Object.values(EnterpriseFeature),
168
+ contact: LICENSE_CONTACT,
169
+ };
137
170
  function loadPublicKey(pem) {
138
171
  if (pem === undefined || 0 === pem.length)
139
172
  return undefined;
@@ -156,42 +189,73 @@ export function describeLicense(now, env = process.env, baked = BAKED_ISSUER_PUB
156
189
  // is off, and assertEnterpriseFromEnv now denies rather than unlocking. Say so loudly here too.
157
190
  return isProductionEnv(env)
158
191
  ? {
159
- status: 'invalid',
192
+ ...LICENSE_OFFER,
193
+ status: LicenseActivation.INVALID,
160
194
  detail: 'MISCONFIGURED — running in production with no issuer key baked; enterprise features are DENIED (rebuild with BAKED_ISSUER_PUBLIC_KEY_PEM stamped in)',
161
195
  }
162
196
  : {
163
- status: 'eval',
197
+ ...LICENSE_OFFER,
198
+ status: LicenseActivation.EVAL,
164
199
  detail: 'evaluation mode: enterprise features run free (no issuer key configured)',
165
200
  };
166
201
  }
167
202
  const publicKey = loadPublicKey(pem);
168
203
  if (publicKey === undefined)
169
- return { status: 'invalid', detail: `${LICENSE_PUBLIC_KEY_ENV} is not a valid public key` };
204
+ return {
205
+ ...LICENSE_OFFER,
206
+ status: LicenseActivation.INVALID,
207
+ detail: `${LICENSE_PUBLIC_KEY_ENV} is not a valid public key`,
208
+ };
170
209
  const check = verifyLicenseKey(env[LICENSE_KEY_ENV], publicKey, now);
171
210
  if (check.status === LicenseStatus.VALID) {
172
- const { org, plan, exp, features } = check.payload;
211
+ const { lid, org, plan, exp, features } = check.payload;
212
+ // A key scoped to features this build does not gate is `active` and unlocks nothing. Both halves
213
+ // were already on screen (`features` against `gated`) and nothing joined them, so the one word a
214
+ // customer actually reads told them they were fine while every gated call refused them.
215
+ const coversNothingHere = features !== undefined && !features.some((f) => LICENSE_OFFER.gated.includes(f));
216
+ const detail = coversNothingHere
217
+ ? `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}. This key covers ${features?.join(', ')}, and nothing this build gates (${LICENSE_OFFER.gated.join(', ')}) is included. Contact ${LICENSE_CONTACT}`
218
+ : `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`;
173
219
  return {
174
- status: 'active',
220
+ contact: LICENSE_CONTACT,
221
+ // The one active case that still needs the list: a key covering nothing this build gates is
222
+ // reported here, and the sentence explaining it is unreadable without both sides named.
223
+ ...(coversNothingHere ? { gated: LICENSE_OFFER.gated } : {}),
224
+ status: LicenseActivation.ACTIVE,
225
+ licenseId: lid,
175
226
  org,
176
227
  plan,
177
228
  expiresAt: exp,
178
229
  ...(features !== undefined ? { features } : {}),
179
- detail: `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`,
230
+ ...(coversNothingHere ? { coversNothingHere } : {}),
231
+ detail,
180
232
  };
181
233
  }
182
234
  if (check.status === LicenseStatus.MISSING) {
183
235
  return {
184
- status: 'missing',
185
- detail: `set ${LICENSE_KEY_ENV} to activate enterprise features in production`,
236
+ ...LICENSE_OFFER,
237
+ status: LicenseActivation.MISSING,
238
+ detail: `set ${LICENSE_KEY_ENV} to activate enterprise features in production. ` +
239
+ `Request a key from ${LICENSE_CONTACT}`,
186
240
  };
187
241
  }
188
242
  if (check.status === LicenseStatus.EXPIRED) {
243
+ // The id rides an expired key so a lapse can be attributed to a CUSTOMER. Without it, telemetry
244
+ // reports that somebody's licence ran out and cannot say whose, which is not a renewal signal.
189
245
  return {
190
- status: 'expired',
191
- detail: 'license expired — renew to keep using enterprise features',
246
+ ...LICENSE_OFFER,
247
+ status: LicenseActivation.EXPIRED,
248
+ licenseId: check.payload.lid,
249
+ org: check.payload.org,
250
+ expiresAt: check.payload.exp,
251
+ detail: `license expired. Renew with ${LICENSE_CONTACT} to keep using enterprise features`,
192
252
  };
193
253
  }
194
- return { status: 'invalid', detail: `license key rejected (${check.status})` };
254
+ return {
255
+ ...LICENSE_OFFER,
256
+ status: LicenseActivation.INVALID,
257
+ detail: `license key rejected (${check.status}). ${LICENSE_CONTACT} can re-issue it`,
258
+ };
195
259
  }
196
260
  /** True in a production runtime (NODE_ENV=production) — where a missing issuer key is a mis-built
197
261
  * release, not an eval session, so the gate must fail CLOSED rather than run features free. */
@@ -1,3 +1,5 @@
1
+ export { SseFrameParser, type SseFrame } from './sse-frame-parser.js';
2
+ export { probeDaemon, waitForDaemon } from './proxy-daemon-probe.js';
1
3
  /**
2
4
  * Re-exported so every existing caller (and every spec) keeps importing the proxy's log from the
3
5
  * proxy. Where the code lives is a file-size decision; where it is imported from is an API.
@@ -92,33 +94,6 @@ export declare class HandshakeReplay {
92
94
  /** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
93
95
  shouldSuppressInbound(line: string): boolean;
94
96
  }
95
- /** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
96
- interface SseFrame {
97
- event: string;
98
- data: string;
99
- }
100
- /**
101
- * Incremental SSE frame parser for the MCP front door.
102
- *
103
- * A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
104
- * server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
105
- * carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
106
- * socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
107
- * get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
108
- * accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
109
- */
110
- export declare class SseFrameParser {
111
- #private;
112
- push(chunk: string): SseFrame[];
113
- }
114
- /**
115
- * Returns true if something is already listening on the reticle port.
116
- * Uses a plain TCP probe so we don't create a side-effectful SSE session
117
- * inside the daemon just to check reachability.
118
- */
119
- export declare function probeDaemon(port: number): Promise<boolean>;
120
- /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
121
- export declare function waitForDaemon(port: number): Promise<void>;
122
97
  /**
123
98
  * Turn an `endpoint` frame's data into a POST target, or null when there is nothing usable in it.
124
99
  *
@@ -2,8 +2,11 @@ import * as http from 'node:http';
2
2
  import { localInitializeResponse, isHandshakeLine, drainLines, MAX_STDIN_LINE_BYTES, } from './proxy-handshake.js';
3
3
  import { ToolCatalogCache } from './tool-catalog-cache.js';
4
4
  import { toolsChangedNotification } from './proxy-handshake.js';
5
- import * as net from 'node:net';
6
5
  import { LOOPBACK_HOST, MCP_SSE_PATH, MCP_SHUTDOWN_EVENT, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
6
+ import { SseFrameParser } from './sse-frame-parser.js';
7
+ export { SseFrameParser } from './sse-frame-parser.js';
8
+ export { probeDaemon, waitForDaemon } from './proxy-daemon-probe.js';
9
+ import { probeDaemon } from './proxy-daemon-probe.js';
7
10
  import { SERVER_VERSION } from '../version/server-version.js';
8
11
  import { buildServerInstructions } from './server-instructions.js';
9
12
  import { hasProjectConnectedBefore } from '../session/connection-memory.js';
@@ -26,20 +29,6 @@ export { accountProxyLogWrite, proxyLog, proxyLogPath, PROXY_LOG_CHECK_BYTES, re
26
29
  import { proxyLog } from './proxy-log.js';
27
30
  import { log } from '../log.js';
28
31
  import { OutageReason, OutageStage, reportMcpOutage } from './mcp-outage.js';
29
- const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
30
- /**
31
- * How long to wait for the spawned daemon's port to accept connections before giving up. The default
32
- * suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
33
- * RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
34
- */
35
- const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
36
- const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
37
- ? envDaemonReadyTimeoutMs
38
- : DEFAULT_DAEMON_READY_TIMEOUT_MS;
39
- const DAEMON_POLL_INTERVAL_MS = 100;
40
- function delay(ms) {
41
- return new Promise((resolve) => setTimeout(resolve, ms));
42
- }
43
32
  /** Reconnect backoff: linear, capped, so a briefly-restarting daemon is picked up fast. */
44
33
  const RECONNECT_BASE_MS = 250;
45
34
  const RECONNECT_CAP_MS = 5_000;
@@ -264,79 +253,6 @@ export class HandshakeReplay {
264
253
  return true;
265
254
  }
266
255
  }
267
- /**
268
- * Incremental SSE frame parser for the MCP front door.
269
- *
270
- * A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
271
- * server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
272
- * carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
273
- * socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
274
- * get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
275
- * accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
276
- */
277
- export class SseFrameParser {
278
- #buffer = '';
279
- #event = '';
280
- #data = '';
281
- push(chunk) {
282
- this.#buffer += chunk;
283
- // Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
284
- // next chunk (it may complete later).
285
- const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
286
- const lines = normalised.split('\n');
287
- this.#buffer = lines.pop() ?? '';
288
- const frames = [];
289
- for (const line of lines) {
290
- if ('' === line) {
291
- if (this.#data !== '') {
292
- frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
293
- }
294
- this.#event = '';
295
- this.#data = '';
296
- }
297
- else if (line.startsWith('event:')) {
298
- this.#event = line.slice(6).trim();
299
- }
300
- else if (line.startsWith('data:')) {
301
- const val = line.slice(5).trim();
302
- this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
303
- }
304
- }
305
- return frames;
306
- }
307
- }
308
- /**
309
- * Returns true if something is already listening on the reticle port.
310
- * Uses a plain TCP probe so we don't create a side-effectful SSE session
311
- * inside the daemon just to check reachability.
312
- */
313
- export function probeDaemon(port) {
314
- return new Promise((resolve) => {
315
- const socket = new net.Socket();
316
- socket.setTimeout(500);
317
- socket.on('connect', () => {
318
- socket.destroy();
319
- resolve(true);
320
- });
321
- socket.on('error', () => resolve(false));
322
- socket.on('timeout', () => {
323
- socket.destroy();
324
- resolve(false);
325
- });
326
- socket.connect(port, LOOPBACK_HOST);
327
- });
328
- }
329
- /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
330
- export async function waitForDaemon(port) {
331
- const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
332
- while (Date.now() < deadline) {
333
- const reachable = await probeDaemon(port);
334
- if (reachable)
335
- return;
336
- await delay(DAEMON_POLL_INTERVAL_MS);
337
- }
338
- throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
339
- }
340
256
  /**
341
257
  * POST one JSON-RPC line into the daemon's session. Resolves null on success, or a short reason
342
258
  * when the request never reached a server that will answer it.
package/dist/mcp/mcp.d.ts CHANGED
@@ -35,7 +35,14 @@ export declare function encodeResult(result: unknown, encoding: string): string;
35
35
  * "Call reticle_tools { names: [...] } for its parameters" — leaving it out made our own advice a
36
36
  * dead end on the one surface that advertises everything.
37
37
  */
38
- export declare function advertisedTools(surface: ToolSurface): ToolDef[];
38
+ export declare function advertisedTools(surface: ToolSurface,
39
+ /**
40
+ * The full table to advertise from. Defaults to what this package ships.
41
+ *
42
+ * A consumer embedding the engine passes its own composition — ours plus its own — so its tools
43
+ * reach the wire without editing the shipped array.
44
+ */
45
+ tools?: readonly ToolDef[]): ToolDef[];
39
46
  /**
40
47
  * Exactly what gets advertised for one tool under a profile: trimmed description, lean input shape,
41
48
  * and (non-lean only) the output schema.
@@ -59,4 +66,13 @@ export declare function createMcpServer(deps: ToolDeps, profile?: ToolSurface,
59
66
  * told to instrument a project that is already wired loses a paragraph, while an agent not told
60
67
  * loses the entire install — and the second is what the field overwhelmingly shows.
61
68
  */
62
- previouslyConnected?: boolean): McpServer;
69
+ previouslyConnected?: boolean,
70
+ /**
71
+ * The full tool table this server serves. Defaults to what this package ships.
72
+ *
73
+ * Threaded rather than read from the module so a consumer can serve its own tools on one surface
74
+ * with ours. It reaches all three places that need the WHOLE table and not just the advertised
75
+ * slice: the profile filter, the `reticle_run` hatch, and the unadvertised-tool help — a consumer
76
+ * tool missing from either of the last two is a tool the agent is actively told does not exist.
77
+ */
78
+ tools?: readonly ToolDef[]): McpServer;
package/dist/mcp/mcp.js CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { z } from 'zod';
4
4
  import { PredicateSchema } from '../events/predicate.js';
5
- import { isToonable, resultToToon } from '@reticlehq/core';
5
+ import { isToonable, resultToToon, MCP_SERVER_NAME } from '@reticlehq/core';
6
6
  import { TOOLS } from '../tools/tools.js';
7
7
  import { filterTools, describeToolSurface, TOOL_SURFACE, } from '../tools/tool-surface.js';
8
8
  import { ReticleTool } from '../tools/tool-names.js';
@@ -18,7 +18,6 @@ import { unadvertisedToolHelp } from '../tools/unadvertised-help.js';
18
18
  const CALL_TOOL_METHOD = 'tools/call';
19
19
  import { log } from '../log.js';
20
20
  import { SERVER_VERSION } from '../version/server-version.js';
21
- import { MCP_SERVER_NAME } from '../init/mcp.js';
22
21
  import { setMcpClientNameHook } from '../telemetry/feedback-context.js';
23
22
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
24
23
  import { parsePredicate } from '../events/predicate-parse.js';
@@ -201,11 +200,24 @@ export function encodeResult(result, encoding) {
201
200
  * "Call reticle_tools { names: [...] } for its parameters" — leaving it out made our own advice a
202
201
  * dead end on the one surface that advertises everything.
203
202
  */
204
- export function advertisedTools(surface) {
203
+ export function advertisedTools(surface,
204
+ /**
205
+ * The full table to advertise from. Defaults to what this package ships.
206
+ *
207
+ * A consumer embedding the engine passes its own composition — ours plus its own — so its tools
208
+ * reach the wire without editing the shipped array.
209
+ */
210
+ tools = TOOLS) {
205
211
  // The catalog reports which surface is live and what chose it — the only way an agent can see that
206
212
  // a setting did not take (the daemon read the value it started with).
207
213
  const origin = describeToolSurface(surface);
208
- return [...filterTools(TOOLS, surface), ...buildDynamicTools(TOOLS, origin)];
214
+ // The profile trims OUR table only. A tool this package does not ship was appended deliberately by
215
+ // whoever is embedding us, and our `CORE_TOOL_NAMES` was never going to contain its name — so
216
+ // filtering it would drop every consumer tool and look exactly like the feature not working.
217
+ const shipped = new Set(TOOLS.map((tool) => tool.name));
218
+ const ours = tools.filter((tool) => shipped.has(tool.name));
219
+ const theirs = tools.filter((tool) => !shipped.has(tool.name));
220
+ return [...filterTools([...ours], surface), ...theirs, ...buildDynamicTools([...tools], origin)];
209
221
  }
210
222
  /**
211
223
  * Exactly what gets advertised for one tool under a profile: trimmed description, lean input shape,
@@ -364,7 +376,16 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT,
364
376
  * told to instrument a project that is already wired loses a paragraph, while an agent not told
365
377
  * loses the entire install — and the second is what the field overwhelmingly shows.
366
378
  */
367
- previouslyConnected = false) {
379
+ previouslyConnected = false,
380
+ /**
381
+ * The full tool table this server serves. Defaults to what this package ships.
382
+ *
383
+ * Threaded rather than read from the module so a consumer can serve its own tools on one surface
384
+ * with ours. It reaches all three places that need the WHOLE table and not just the advertised
385
+ * slice: the profile filter, the `reticle_run` hatch, and the unadvertised-tool help — a consumer
386
+ * tool missing from either of the last two is a tool the agent is actively told does not exist.
387
+ */
388
+ tools = TOOLS) {
368
389
  const encoding = (process.env[ENCODING_ENV] ?? '').toLowerCase();
369
390
  // Which surface this daemon advertises. The 18-tool default and the 48-tool full surface are
370
391
  // different products from inside an agent's context, so outcomes are only comparable when the
@@ -412,13 +433,13 @@ previouslyConnected = false) {
412
433
  // 11 tools were unreachable with no escape hatch — including reticle_annotate, which
413
434
  // reticle_flow_save's own description instructs the agent to call. `full` advertises everything
414
435
  // directly and needs no hatch.
415
- const advertised = advertisedTools(profile);
436
+ const advertised = advertisedTools(profile, tools);
416
437
  installFriendlyArgErrors(server, new Map(advertised
417
438
  .filter((tool) => tool.example !== undefined)
418
439
  .map((tool) => [tool.name, JSON.stringify(tool.example)])),
419
- // Every tool in TOOLS, not just the advertised ones: reticle_run dispatches to the full table,
420
- // so an unadvertised tool reached through the hatch must be just as strict.
421
- new Map(TOOLS.map((tool) => [tool.name, new Set(Object.keys(tool.inputSchema))])));
440
+ // Every tool in the table, not just the advertised ones: reticle_run dispatches to the full
441
+ // table, so an unadvertised tool reached through the hatch must be just as strict.
442
+ new Map(tools.map((tool) => [tool.name, new Set(Object.keys(tool.inputSchema))])));
422
443
  for (const tool of advertised) {
423
444
  // Output schemas are now the largest slice of the per-request tax (55.6% of the hybrid payload
424
445
  // after the predicate fix) and we are the only one of the three MCP servers that sends them.
@@ -480,6 +501,6 @@ previouslyConnected = false) {
480
501
  // `tools/call` dispatcher lazily on the FIRST registerTool, so wrapping before this point finds
481
502
  // nothing to wrap and silently does nothing. Verified live rather than assumed — the first attempt
482
503
  // sat here doing exactly that.
483
- installUnadvertisedToolHelp(server, new Set(advertised.map((tool) => tool.name)), new Set(TOOLS.map((tool) => tool.name)));
504
+ installUnadvertisedToolHelp(server, new Set(advertised.map((tool) => tool.name)), new Set(tools.map((tool) => tool.name)));
484
505
  return server;
485
506
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns true if something is already listening on the reticle port.
3
+ * Uses a plain TCP probe so we don't create a side-effectful SSE session
4
+ * inside the daemon just to check reachability.
5
+ */
6
+ export declare function probeDaemon(port: number): Promise<boolean>;
7
+ /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
8
+ export declare function waitForDaemon(port: number): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import * as net from 'node:net';
2
+ import { LOOPBACK_HOST } from '@reticlehq/core';
3
+ const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
4
+ /**
5
+ * How long to wait for the spawned daemon's port to accept connections before giving up. The default
6
+ * suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
7
+ * RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
8
+ */
9
+ const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
10
+ const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
11
+ ? envDaemonReadyTimeoutMs
12
+ : DEFAULT_DAEMON_READY_TIMEOUT_MS;
13
+ const DAEMON_POLL_INTERVAL_MS = 100;
14
+ function delay(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+ /**
18
+ * Returns true if something is already listening on the reticle port.
19
+ * Uses a plain TCP probe so we don't create a side-effectful SSE session
20
+ * inside the daemon just to check reachability.
21
+ */
22
+ export function probeDaemon(port) {
23
+ return new Promise((resolve) => {
24
+ const socket = new net.Socket();
25
+ socket.setTimeout(500);
26
+ socket.on('connect', () => {
27
+ socket.destroy();
28
+ resolve(true);
29
+ });
30
+ socket.on('error', () => resolve(false));
31
+ socket.on('timeout', () => {
32
+ socket.destroy();
33
+ resolve(false);
34
+ });
35
+ socket.connect(port, LOOPBACK_HOST);
36
+ });
37
+ }
38
+ /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
39
+ export async function waitForDaemon(port) {
40
+ const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
41
+ while (Date.now() < deadline) {
42
+ const reachable = await probeDaemon(port);
43
+ if (reachable)
44
+ return;
45
+ await delay(DAEMON_POLL_INTERVAL_MS);
46
+ }
47
+ throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
48
+ }
@@ -14,8 +14,8 @@
14
14
  * Safe because the proxy replays the client's own `initialize` to the daemon whenever a session is
15
15
  * finally established (see `replayLines`), so the daemon still gets its handshake in order.
16
16
  */
17
- import { MCP_SERVER_NAME } from '../init/mcp.js';
18
17
  import { SERVER_VERSION } from '../version/server-version.js';
18
+ import { MCP_SERVER_NAME } from '@reticlehq/core';
19
19
  /** The version we answer with if the client proposed none. */
20
20
  const FALLBACK_PROTOCOL_VERSION = '2024-11-05';
21
21
  /**
@@ -22,7 +22,7 @@ import { SHARED_PARAM_GUIDANCE } from './shared-params.js';
22
22
  /** The tools, and the rule that only two of them decide anything. Constant across both states. */
23
23
  const VERDICT_DISCIPLINE = `Reticle verifies a running web app from the inside: look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
24
24
 
25
- Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. A verdict of verified:"unknown" is not a pass: it means Reticle drove the app and could not tell what happened. Report it as unknown.`;
25
+ Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. Only verified:"yes" is a pass — "unknown" means Reticle could not tell what happened, "no-fault" means nothing was declared to prove. Report either as not proved.`;
26
26
  /**
27
27
  * The first move, for a project no app has ever connected to.
28
28
  *
@@ -41,7 +41,7 @@ const FEEDBACK_ASK = `Feedback is first-class here, not a courtesy. Reticle is b
41
41
  - You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
42
42
  - The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
43
43
 
44
- Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened"\`. Report defects in RETICLE — a bug you find in the app under test is Reticle working, and belongs in your answer to the user.`;
44
+ Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <one of those kinds> "what happened"\`. Report defects in RETICLE — a bug you find in the app under test is Reticle working, and belongs in your answer to the user.`;
45
45
  /** The instructions this daemon should advertise, given what it knows about the project. */
46
46
  /**
47
47
  * Instructions are sent ONCE at initialize; the tool surface is re-sent every turn. Measured on this
@@ -0,0 +1,19 @@
1
+ /** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
2
+ export interface SseFrame {
3
+ event: string;
4
+ data: string;
5
+ }
6
+ /**
7
+ * Incremental SSE frame parser for the MCP front door.
8
+ *
9
+ * A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
10
+ * server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
11
+ * carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
12
+ * socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
13
+ * get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
14
+ * accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
15
+ */
16
+ export declare class SseFrameParser {
17
+ #private;
18
+ push(chunk: string): SseFrame[];
19
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Incremental SSE frame parser for the MCP front door.
3
+ *
4
+ * A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
5
+ * server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
6
+ * carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
7
+ * socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
8
+ * get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
9
+ * accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
10
+ */
11
+ export class SseFrameParser {
12
+ #buffer = '';
13
+ #event = '';
14
+ #data = '';
15
+ push(chunk) {
16
+ this.#buffer += chunk;
17
+ // Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
18
+ // next chunk (it may complete later).
19
+ const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
20
+ const lines = normalised.split('\n');
21
+ this.#buffer = lines.pop() ?? '';
22
+ const frames = [];
23
+ for (const line of lines) {
24
+ if ('' === line) {
25
+ if (this.#data !== '') {
26
+ frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
27
+ }
28
+ this.#event = '';
29
+ this.#data = '';
30
+ }
31
+ else if (line.startsWith('event:')) {
32
+ this.#event = line.slice(6).trim();
33
+ }
34
+ else if (line.startsWith('data:')) {
35
+ const val = line.slice(5).trim();
36
+ this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
37
+ }
38
+ }
39
+ return frames;
40
+ }
41
+ }