@reticlehq/server 2.9.0 → 2.10.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 (71) hide show
  1. package/SKILL.md +17 -17
  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/ee/audit-log.js +2 -2
  9. package/dist/events/body-failures.d.ts +2 -2
  10. package/dist/events/contradiction-folds.d.ts +45 -0
  11. package/dist/events/contradiction-folds.js +42 -0
  12. package/dist/events/contradictions.d.ts +19 -1
  13. package/dist/events/contradictions.js +17 -2
  14. package/dist/events/echo-mismatch.d.ts +2 -2
  15. package/dist/events/stale-response.d.ts +2 -3
  16. package/dist/events/stale-response.js +49 -2
  17. package/dist/events/unit-mismatch.d.ts +2 -2
  18. package/dist/flows/flow-tools.js +1 -1
  19. package/dist/flows/verify-change-tools.js +1 -1
  20. package/dist/honesty/verified.js +16 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -0
  23. package/dist/init/mcp.d.ts +1 -2
  24. package/dist/init/mcp.js +4 -1
  25. package/dist/license/license.d.ts +69 -4
  26. package/dist/license/license.js +84 -15
  27. package/dist/mcp/mcp-proxy.d.ts +2 -27
  28. package/dist/mcp/mcp-proxy.js +4 -88
  29. package/dist/mcp/mcp.d.ts +18 -2
  30. package/dist/mcp/mcp.js +31 -10
  31. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  32. package/dist/mcp/proxy-daemon-probe.js +48 -0
  33. package/dist/mcp/proxy-handshake.js +1 -1
  34. package/dist/mcp/server-instructions.js +2 -2
  35. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  36. package/dist/mcp/sse-frame-parser.js +41 -0
  37. package/dist/session/no-session-diagnosis.d.ts +7 -0
  38. package/dist/session/no-session-diagnosis.js +23 -2
  39. package/dist/session/no-session-watch.js +7 -0
  40. package/dist/session/session-readiness.js +1 -1
  41. package/dist/telemetry/daemon-telemetry.js +7 -2
  42. package/dist/telemetry/instrumentation-stall.d.ts +7 -0
  43. package/dist/telemetry/instrumentation-stall.js +13 -0
  44. package/dist/telemetry/license-activation.d.ts +48 -0
  45. package/dist/telemetry/license-activation.js +57 -0
  46. package/dist/telemetry/telemetry.js +10 -1
  47. package/dist/tools/dynamic-tools.js +1 -1
  48. package/dist/tools/error-recovery.js +1 -1
  49. package/dist/tools/feedback-tools.js +31 -6
  50. package/dist/tools/invoke-tool.d.ts +1 -1
  51. package/dist/tools/invoke-tool.js +10 -5
  52. package/dist/tools/tool-kit.d.ts +30 -3
  53. package/dist/tools/tool-names.d.ts +2 -0
  54. package/dist/tools/tool-names.js +2 -0
  55. package/dist/tools/tool-surface.js +2 -3
  56. package/dist/tools/tools.js +16 -1
  57. package/docs/agent-cheatsheet.md +2 -2
  58. package/docs/capabilities.mdx +4 -4
  59. package/docs/cli/hunt.mdx +2 -2
  60. package/docs/deploy-checks.md +2 -2
  61. package/docs/docs.json +1 -1
  62. package/docs/enterprise.md +7 -3
  63. package/docs/flows.md +4 -4
  64. package/docs/gates.md +1 -1
  65. package/docs/recipes.mdx +2 -2
  66. package/docs/telemetry-contract.md +17 -0
  67. package/docs/tools-overview.mdx +1 -1
  68. package/docs/tools-session-and-feedback.mdx +5 -0
  69. package/docs/tools-tools-and-run.mdx +15 -15
  70. package/docs/usage.md +3 -3
  71. package/package.json +2 -2
@@ -133,7 +133,23 @@ export function decideVerified(inputs) {
133
133
  // A vacuous green: nothing was actually proved. Grade NONE means no signal, no request, no state
134
134
  // change and no element was pinned — the assertion could not have failed, which makes passing it
135
135
  // evidence of nothing.
136
+ // Two different facts used to share this answer. Everything above has already ruled out a failure,
137
+ // a contradiction, a dirty capture, a pending write and an unread one — so when the window ALSO
138
+ // settled, the engine did not fail to see: it saw the whole window, ran every oracle, and found
139
+ // nothing wrong. That is `no-fault`, and telling it apart from "I could not see" is the difference
140
+ // between "assert something" and "look again with better coverage".
141
+ //
142
+ // Gated on `true === settled` rather than on a caller's say-so. A call that returned before the app
143
+ // stopped moving has not earned this, whatever it declared — which is what stops `no-fault` from
144
+ // becoming the green-forever button an always-available "nothing was wrong" would be.
136
145
  if (honesty.grade === HonestyGrade.NONE) {
146
+ if (true === settled) {
147
+ return {
148
+ verified: Verified.NO_FAULT,
149
+ verifiedReason: VerifiedReason.NOTHING_DECLARED,
150
+ because: 'the page settled and no channel reported a problem, but nothing was declared to prove — this is not verification. Assert a consequence the action CHANGES (a signal, a request, a route, or store state) to get one',
151
+ };
152
+ }
137
153
  return {
138
154
  verified: Verified.UNKNOWN,
139
155
  verifiedReason: VerifiedReason.VACUOUS_GRADE,
package/dist/index.d.ts CHANGED
@@ -28,6 +28,24 @@ export { VisualStore } from './visual/visual-store.js';
28
28
  export { diffPng } from './visual/visual-diff.js';
29
29
  export type { VisualDiffResult, VisualRect, DiffOptions } from './visual/visual-diff.js';
30
30
  export { crawl } from './crawl/crawl.js';
31
+ /**
32
+ * The contradiction pass, and the seam a consumer adds its own rules through.
33
+ *
34
+ * Exported because a service embedding this engine grades a recording the same way the tools do, and
35
+ * without these it would have to re-implement the fold — a second implementation of the one thing
36
+ * this product is judged on, and the one nobody dogfoods is the one that rots.
37
+ */
38
+ export { findContradictions } from './events/contradictions.js';
39
+ /**
40
+ * The MCP server factory, so a consumer can serve the tool surface it composed.
41
+ *
42
+ * Without this the composition seam is unreachable from outside the package: a consumer can build the
43
+ * list and has nothing to hand it to.
44
+ */
45
+ export { createMcpServer } from './mcp/mcp.js';
46
+ export type { Contradiction, ContradictionOptions } from './events/contradictions.js';
47
+ export { registerContradictionFold, registeredContradictionFolds, } from './events/contradiction-folds.js';
48
+ export type { ContradictionFold } from './events/contradiction-folds.js';
31
49
  export { MCP_SSE_PATH, MCP_MESSAGE_PATH } from '@reticlehq/core';
32
50
  export { BrowserPool, DEFAULT_LEASE_TTL_MS } from './pool/browser-pool.js';
33
51
  export type { Lease, Launcher, PooledBrowser } from './pool/browser-pool.js';
package/dist/index.js CHANGED
@@ -72,6 +72,22 @@ export { ProjectStore } from './project/project-store.js';
72
72
  export { VisualStore } from './visual/visual-store.js';
73
73
  export { diffPng } from './visual/visual-diff.js';
74
74
  export { crawl } from './crawl/crawl.js';
75
+ /**
76
+ * The contradiction pass, and the seam a consumer adds its own rules through.
77
+ *
78
+ * Exported because a service embedding this engine grades a recording the same way the tools do, and
79
+ * without these it would have to re-implement the fold — a second implementation of the one thing
80
+ * this product is judged on, and the one nobody dogfoods is the one that rots.
81
+ */
82
+ export { findContradictions } from './events/contradictions.js';
83
+ /**
84
+ * The MCP server factory, so a consumer can serve the tool surface it composed.
85
+ *
86
+ * Without this the composition seam is unreachable from outside the package: a consumer can build the
87
+ * list and has nothing to hand it to.
88
+ */
89
+ export { createMcpServer } from './mcp/mcp.js';
90
+ export { registerContradictionFold, registeredContradictionFolds, } from './events/contradiction-folds.js';
75
91
  export { MCP_SSE_PATH, MCP_MESSAGE_PATH } from '@reticlehq/core';
76
92
  export { BrowserPool, DEFAULT_LEASE_TTL_MS } from './pool/browser-pool.js';
77
93
  export { playwrightLauncher, resolveMaxContexts } from './pool/playwright-launcher.js';
@@ -11,7 +11,7 @@
11
11
  * project's `.reticle.json` in its CWD (see cli-port.ts). The port belongs to the project, not the
12
12
  * global agent config.
13
13
  */
14
- export declare const MCP_SERVER_NAME = "reticle";
14
+ export { MCP_SERVER_NAME } from '@reticlehq/core';
15
15
  /**
16
16
  * The registered command is STILL bare `npx` on every platform, deliberately.
17
17
  *
@@ -76,4 +76,3 @@ export declare function claudeAvailableProbe(): {
76
76
  };
77
77
  /** Printed when the `claude` CLI isn't available — register Reticle globally once, by hand. */
78
78
  export declare function mcpManual(): string;
79
- export {};
package/dist/init/mcp.js CHANGED
@@ -12,7 +12,10 @@
12
12
  * global agent config.
13
13
  */
14
14
  import { RETICLE_NPM_PACKAGE } from '../version/server-version.js';
15
- export const MCP_SERVER_NAME = 'reticle';
15
+ // The name is a wire identity and lives in the contract; re-exported here so the registration writers
16
+ // below (and their tests) keep reading it from the module that USES it.
17
+ export { MCP_SERVER_NAME } from '@reticlehq/core';
18
+ import { MCP_SERVER_NAME } from '@reticlehq/core';
16
19
  /**
17
20
  * The registered command is STILL bare `npx` on every platform, deliberately.
18
21
  *
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { type KeyObject } 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 declare const LicenseStatus: {
17
18
  readonly VALID: "valid";
@@ -21,18 +22,31 @@ export declare const LicenseStatus: {
21
22
  readonly EXPIRED: "expired";
22
23
  };
23
24
  export type LicenseStatus = (typeof LicenseStatus)[keyof typeof LicenseStatus];
24
- /** The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features unlock. */
25
+ /**
26
+ * The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features
27
+ * unlock.
28
+ *
29
+ * `lid` is the STABLE LICENSE ID and the only safe join key for per-customer usage: `org` is a display
30
+ * name a human typed at signing time, so two customers called "Acme" merge into one and a customer who
31
+ * renames splits into two. Every key carries one (minted by the issuer, `scripts/issue-license.mjs`),
32
+ * which is why it is required rather than optional — an optional id is one that goes missing on exactly
33
+ * the keys somebody later needs to attribute, and a key without it should be re-issued, not silently
34
+ * counted as nobody.
35
+ */
25
36
  declare const LicensePayloadSchema: z.ZodObject<{
37
+ lid: z.ZodString;
26
38
  org: z.ZodString;
27
39
  plan: z.ZodString;
28
40
  exp: z.ZodNumber;
29
41
  features: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
30
42
  }, "strip", z.ZodTypeAny, {
43
+ lid: string;
31
44
  org: string;
32
45
  plan: string;
33
46
  exp: number;
34
47
  features?: string[] | undefined;
35
48
  }, {
49
+ lid: string;
36
50
  org: string;
37
51
  plan: string;
38
52
  exp: number;
@@ -42,13 +56,44 @@ export type LicensePayload = z.infer<typeof LicensePayloadSchema>;
42
56
  type LicenseCheck = {
43
57
  status: typeof LicenseStatus.VALID;
44
58
  payload: LicensePayload;
59
+ }
60
+ /**
61
+ * EXPIRED carries the payload; the other failures cannot. The line is the SIGNATURE, not the
62
+ * clock: an expired key was genuinely issued by us and its claims are exactly as trustworthy as
63
+ * they were yesterday, so the licence id is safe to use. A malformed or wrongly-signed key has no
64
+ * verified claims at all, and naming a customer from one would mean trusting whatever was pasted.
65
+ */
66
+ | {
67
+ status: typeof LicenseStatus.EXPIRED;
68
+ payload: LicensePayload;
45
69
  } | {
46
- status: Exclude<LicenseStatus, typeof LicenseStatus.VALID>;
70
+ status: Exclude<LicenseStatus, typeof LicenseStatus.VALID | typeof LicenseStatus.EXPIRED>;
47
71
  };
48
72
  /** Sign a payload into a license key — the ISSUER side (Reticle's private key). Exposed for the issuer tool + tests. */
49
73
  export declare function signLicenseKey(payload: LicensePayload, privateKey: KeyObject): string;
50
74
  /** Verify a key against the issuer public key. Never throws — returns a structured status. */
51
75
  export declare function verifyLicenseKey(key: string | undefined, publicKey: KeyObject, now: number): LicenseCheck;
76
+ /**
77
+ * Where to get a key. A named constant because it appears in the gate's error AND in what
78
+ * `reticle license` prints, and a contact address that drifts between the two is a support request
79
+ * that reaches nobody.
80
+ */
81
+ export declare const LICENSE_CONTACT = "hey@reticle.sh";
82
+ /**
83
+ * The features a licence gates in THIS build. The single source: `assertEnterprise` callers name a
84
+ * member rather than a string literal, and `reticle license` reports the list, so what the CLI claims
85
+ * is gated is derived from what is actually gated rather than a second list somebody maintains.
86
+ *
87
+ * It is deliberately short. Reticle's roadmap for enterprise is longer (SSO, SCIM, RBAC, multi-org,
88
+ * policy gates), and none of it is here yet — printing a roadmap as though it were shipped is how a
89
+ * buyer discovers on day two that the thing they paid for does not exist. What is listed is what the
90
+ * running build will actually refuse without a key.
91
+ */
92
+ export declare const EnterpriseFeature: {
93
+ /** Recording an audit event. */
94
+ readonly AUDIT_LOG: "audit-log";
95
+ };
96
+ export type EnterpriseFeature = (typeof EnterpriseFeature)[keyof typeof EnterpriseFeature];
52
97
  /** Thrown when a production-mode enterprise feature is used without a valid license. */
53
98
  export declare class EnterpriseLicenseError extends Error {
54
99
  readonly feature: string;
@@ -71,8 +116,28 @@ export declare function assertEnterprise(feature: string, ctx: GateContext): voi
71
116
  export declare const LICENSE_KEY_ENV = "RETICLE_LICENSE_KEY";
72
117
  export declare const LICENSE_PUBLIC_KEY_ENV = "RETICLE_LICENSE_PUBLIC_KEY";
73
118
  /** The human-facing state of enterprise activation on this machine (what `reticle license status` shows). */
74
- interface LicenseReport {
75
- status: 'active' | 'missing' | 'invalid' | 'expired' | 'eval';
119
+ export interface LicenseReport {
120
+ /**
121
+ * Typed against core's closed list rather than re-listing the strings here: this status is reported
122
+ * on every telemetry event, so a member added in one place and not the other is a silently
123
+ * miscounted column (telemetry contract, rule 4).
124
+ */
125
+ status: LicenseActivation;
126
+ /** The stable license id — what usage is attributed to. Present only when `status` is `active`. */
127
+ licenseId?: string;
128
+ /**
129
+ * What a licence unlocks in THIS build, and where to get one. Always reported, including on an
130
+ * unlicensed install: `reticle license` was the one command that talks about licensing and it named
131
+ * neither, so it told an interested reader to set a variable without saying what it would unlock or
132
+ * how to obtain it. A dead end at exactly the moment somebody is asking.
133
+ */
134
+ gated: readonly string[];
135
+ contact: string;
136
+ /**
137
+ * The key is valid, and scoped to features this build does not gate, so it unlocks nothing here.
138
+ * Present only when true: a `false` on every healthy licence is noise on the common case.
139
+ */
140
+ coversNothingHere?: boolean;
76
141
  org?: string;
77
142
  plan?: string;
78
143
  expiresAt?: number;
@@ -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,15 +80,35 @@ 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;
@@ -122,7 +154,7 @@ export const LICENSE_PUBLIC_KEY_ENV = 'RETICLE_LICENSE_PUBLIC_KEY';
122
154
  * never setting RETICLE_LICENSE_PUBLIC_KEY, because the baked key takes precedence. The public key is
123
155
  * safe to ship openly — it can only verify licenses, never mint them (that needs the private key).
124
156
  */
125
- const BAKED_ISSUER_PUBLIC_KEY_PEM = '';
157
+ const BAKED_ISSUER_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAcg+u61Fs5pwDHcYxFGhaxVaQoUbAdyl+RUdSViZyxDY=\n-----END PUBLIC KEY-----";
126
158
  /**
127
159
  * Resolve the issuer public-key PEM enforcement uses: the baked-in release key wins, so it can't be
128
160
  * turned off from the environment. Only when nothing is baked (dev/repo) does the env var apply — that
@@ -134,6 +166,15 @@ function resolveIssuerPublicKeyPem(env, baked = BAKED_ISSUER_PUBLIC_KEY_PEM) {
134
166
  const envPem = env[LICENSE_PUBLIC_KEY_ENV];
135
167
  return envPem !== undefined && envPem.length > 0 ? envPem : undefined;
136
168
  }
169
+ /**
170
+ * The two fields every report carries, spread into each branch. One object rather than a field on each
171
+ * return, because the value that must never go missing is the one on the branch nobody was thinking
172
+ * about — and the branch an unlicensed reader lands on is exactly that branch.
173
+ */
174
+ const LICENSE_OFFER = {
175
+ gated: Object.values(EnterpriseFeature),
176
+ contact: LICENSE_CONTACT,
177
+ };
137
178
  function loadPublicKey(pem) {
138
179
  if (pem === undefined || 0 === pem.length)
139
180
  return undefined;
@@ -156,42 +197,70 @@ export function describeLicense(now, env = process.env, baked = BAKED_ISSUER_PUB
156
197
  // is off, and assertEnterpriseFromEnv now denies rather than unlocking. Say so loudly here too.
157
198
  return isProductionEnv(env)
158
199
  ? {
159
- status: 'invalid',
200
+ ...LICENSE_OFFER,
201
+ status: LicenseActivation.INVALID,
160
202
  detail: 'MISCONFIGURED — running in production with no issuer key baked; enterprise features are DENIED (rebuild with BAKED_ISSUER_PUBLIC_KEY_PEM stamped in)',
161
203
  }
162
204
  : {
163
- status: 'eval',
205
+ ...LICENSE_OFFER,
206
+ status: LicenseActivation.EVAL,
164
207
  detail: 'evaluation mode: enterprise features run free (no issuer key configured)',
165
208
  };
166
209
  }
167
210
  const publicKey = loadPublicKey(pem);
168
211
  if (publicKey === undefined)
169
- return { status: 'invalid', detail: `${LICENSE_PUBLIC_KEY_ENV} is not a valid public key` };
212
+ return {
213
+ ...LICENSE_OFFER,
214
+ status: LicenseActivation.INVALID,
215
+ detail: `${LICENSE_PUBLIC_KEY_ENV} is not a valid public key`,
216
+ };
170
217
  const check = verifyLicenseKey(env[LICENSE_KEY_ENV], publicKey, now);
171
218
  if (check.status === LicenseStatus.VALID) {
172
- const { org, plan, exp, features } = check.payload;
219
+ const { lid, org, plan, exp, features } = check.payload;
220
+ // A key scoped to features this build does not gate is `active` and unlocks nothing. Both halves
221
+ // were already on screen (`features` against `gated`) and nothing joined them, so the one word a
222
+ // customer actually reads told them they were fine while every gated call refused them.
223
+ const coversNothingHere = features !== undefined && !features.some((f) => LICENSE_OFFER.gated.includes(f));
224
+ const detail = coversNothingHere
225
+ ? `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}`
226
+ : `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`;
173
227
  return {
174
- status: 'active',
228
+ ...LICENSE_OFFER,
229
+ status: LicenseActivation.ACTIVE,
230
+ licenseId: lid,
175
231
  org,
176
232
  plan,
177
233
  expiresAt: exp,
178
234
  ...(features !== undefined ? { features } : {}),
179
- detail: `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`,
235
+ ...(coversNothingHere ? { coversNothingHere } : {}),
236
+ detail,
180
237
  };
181
238
  }
182
239
  if (check.status === LicenseStatus.MISSING) {
183
240
  return {
184
- status: 'missing',
185
- detail: `set ${LICENSE_KEY_ENV} to activate enterprise features in production`,
241
+ ...LICENSE_OFFER,
242
+ status: LicenseActivation.MISSING,
243
+ detail: `set ${LICENSE_KEY_ENV} to activate enterprise features in production. ` +
244
+ `Request a key from ${LICENSE_CONTACT}`,
186
245
  };
187
246
  }
188
247
  if (check.status === LicenseStatus.EXPIRED) {
248
+ // The id rides an expired key so a lapse can be attributed to a CUSTOMER. Without it, telemetry
249
+ // reports that somebody's licence ran out and cannot say whose, which is not a renewal signal.
189
250
  return {
190
- status: 'expired',
191
- detail: 'license expired — renew to keep using enterprise features',
251
+ ...LICENSE_OFFER,
252
+ status: LicenseActivation.EXPIRED,
253
+ licenseId: check.payload.lid,
254
+ org: check.payload.org,
255
+ expiresAt: check.payload.exp,
256
+ detail: `license expired. Renew with ${LICENSE_CONTACT} to keep using enterprise features`,
192
257
  };
193
258
  }
194
- return { status: 'invalid', detail: `license key rejected (${check.status})` };
259
+ return {
260
+ ...LICENSE_OFFER,
261
+ status: LicenseActivation.INVALID,
262
+ detail: `license key rejected (${check.status}). ${LICENSE_CONTACT} can re-issue it`,
263
+ };
195
264
  }
196
265
  /** True in a production runtime (NODE_ENV=production) — where a missing issuer key is a mis-built
197
266
  * 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;