@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
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
+ }
@@ -100,6 +100,13 @@ export interface NoSessionFacts {
100
100
  * that a reader deserves to check it.
101
101
  */
102
102
  searchedDirectories?: readonly string[];
103
+ /**
104
+ * How long this daemon has been running with no app connected, in milliseconds.
105
+ *
106
+ * Present only when the stall clock is active and no app has arrived. Used to surface "the
107
+ * install never finished" to the user-facing diagnosis rather than only to telemetry.
108
+ */
109
+ daemonUpMs?: number;
103
110
  }
104
111
  /**
105
112
  * Every branch ends with this, and `recoveryFor` keys on it to suppress the generic no-session
@@ -19,6 +19,7 @@
19
19
  * the hot resolve() path and this stays unit-testable.
20
20
  */
21
21
  import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
22
+ import { STALL_AFTER_MS } from '../telemetry/instrumentation-stall.js';
22
23
  /** The one framework whose most likely cause differs from every other framework's. */
23
24
  const NUXT = 'nuxt';
24
25
  /**
@@ -248,6 +249,26 @@ function searchedClause(facts) {
248
249
  return '';
249
250
  return ` I looked in: ${searched.join(', ')}. If your app is not in that list, that is the answer — this daemon is standing somewhere else.`;
250
251
  }
252
+ /**
253
+ * A leading sentence when the daemon has been up long enough that no app is coming.
254
+ *
255
+ * Only fires for a WIRED project (initialized) that has NEVER connected and is past the
256
+ * threshold. An unwired project already names the install gap as its primary cause; adding a time
257
+ * sentence there would double-blame.
258
+ */
259
+ function stallClause(facts) {
260
+ const { daemonUpMs, initialized, everConnected } = facts;
261
+ if (everConnected)
262
+ return '';
263
+ if (!initialized)
264
+ return '';
265
+ if (daemonUpMs === undefined || daemonUpMs < STALL_AFTER_MS)
266
+ return '';
267
+ const minutes = Math.round(daemonUpMs / 60_000);
268
+ return (`This daemon has been running for ${String(minutes)} minutes and no app has connected. ` +
269
+ 'The most likely cause is that the dev server was not restarted after adding the Reticle ' +
270
+ 'plugin, so the running bundle carries no SDK. Restart the dev server first. ');
271
+ }
251
272
  export function diagnoseNoSession(facts) {
252
273
  const { everConnected, initialized, listening, port } = facts;
253
274
  // Named when known: a claim about a missing file is a claim about ONE directory.
@@ -322,7 +343,7 @@ export function diagnoseNoSession(facts) {
322
343
  'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
323
344
  `carries the SDK without that file at all.${searchedClause(facts)} ${URL_THEN_LEASE} ${RETRY}`);
324
345
  }
325
- return (`no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
346
+ return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
326
347
  'Nothing is listening on the ports Reticle scans ' +
327
348
  `(${SCANNED_PORTS}) either, and the most common reason for that is a dev server that is not ` +
328
349
  'running: start it yourself in the background with the command in `next_action` — it is read ' +
@@ -353,7 +374,7 @@ export function diagnoseNoSession(facts) {
353
374
  `has one, the app is wired and simply has no page open — ${OPEN_CMD}. ` +
354
375
  `${unattributedListeners(listening)}${searchedClause(facts)} ${RETRY}`);
355
376
  }
356
- return ('no browser session connected, and this daemon has never seen one for this project, which is ' +
377
+ return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
357
378
  `wired for Reticle. ${OPEN_THE_APP} ${unattributedListeners(listening)} ` +
358
379
  'If the page IS open and still does not appear, the app is wired and the SDK is not reaching ' +
359
380
  `this daemon (on ${String(port)}). ${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`);
@@ -17,6 +17,7 @@ import { readProjectFramework, readProjectId, readProjectPort } from '../cli/cli
17
17
  import { discoverProjectConfigs } from '../cli/config-discovery.js';
18
18
  import { hasProjectConnectedBefore, rememberConnected } from './connection-memory.js';
19
19
  import { reticleStateHome } from '../daemon/daemon.js';
20
+ import { stallUptime } from '../telemetry/instrumentation-stall.js';
20
21
  /** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
21
22
  const REFRESH_MS = 15_000;
22
23
  /** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
@@ -193,6 +194,12 @@ export function startNoSessionWatch(options) {
193
194
  return framework === undefined ? {} : { framework };
194
195
  })(),
195
196
  leaseExpired: (options.reapedLeases?.() ?? 0) > 0,
197
+ // How long this daemon has been waiting with no app. The diagnosis uses it to surface
198
+ // "install never finished" — the same condition telemetry already knows about.
199
+ ...(() => {
200
+ const upMs = stallUptime(Date.now());
201
+ return upMs === undefined ? {} : { daemonUpMs: upMs };
202
+ })(),
196
203
  // Read here rather than at boot: `.reticle.json` can be written by `init` after this daemon
197
204
  // started, which is the ordinary first-install order, and a port cached from before it existed
198
205
  // would make the daemon confidently report no mismatch on the one run where there is one.
@@ -11,7 +11,7 @@ const DEFAULT_POLL_MS = 25;
11
11
  */
12
12
  export const RETICLE_LOOP_GUIDE = 'Reticle loop — LOOK: reticle_snapshot / reticle_query / reticle_inspect · ACT: reticle_act (or reticle_act_and_wait) · ' +
13
13
  'OBSERVE: reticle_observe / reticle_wait_for / reticle_network / reticle_console · ASSERT: reticle_assert over program ' +
14
- 'truth, not just the DOM · REGRESS: reticle_record{action:"start"} → reticle_replay, or reticle_flow_verify for the whole ' +
14
+ 'truth, not just the DOM · REGRESS: reticle_record{action:"start"} → reticle_replay, or reticle_verify{action:"flows"} for the whole ' +
15
15
  'suite. The human can flag bugs from the panel — drain them with reticle_session{action:"review"} and resolve each once fixed. ' +
16
16
  'MANDATORY: the moment you stop driving — finishing your reply or waiting on the human — call reticle_session {action:"yield"} ' +
17
17
  '(mode:"waiting", or mode:"ask" with the question) so the panel shows your real state; reticle_session{action:"end"} ' +
@@ -13,7 +13,7 @@ import { profileProject } from './project-profile.js';
13
13
  import { startUpdateCheck } from '../update/update-nudge.js';
14
14
  import { markDaemonStart } from './mcp-connection.js';
15
15
  import { markInstrumentationClock } from './app-instrumented.js';
16
- import { markStallClock, reportInstrumentationStall } from './instrumentation-stall.js';
16
+ import { markStallClock, reportInstrumentationStall, STALL_AFTER_MS, } from './instrumentation-stall.js';
17
17
  import { readProjectId } from '../cli/cli-port.js';
18
18
  /** Let the daemon finish coming up before walking the source tree for a profile. */
19
19
  const PROJECT_PROFILE_DELAY_MS = 5_000;
@@ -80,12 +80,17 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
80
80
  // after the early return would make the one case this event exists for the one case it never
81
81
  // sees. It is self-limiting (once per run, never after an app connects), so running it on every
82
82
  // tick costs a comparison.
83
- reportInstrumentationStall({
83
+ const stalled = reportInstrumentationStall({
84
84
  // Same test `app_instrumented` uses for the same field, so the success and failure events
85
85
  // stay directly comparable: a stamped projectId means `init` has run here.
86
86
  initialized: readProjectId(cwd) !== undefined,
87
87
  agentAttached: metrics.agentEverAttached,
88
88
  }, now);
89
+ if (stalled) {
90
+ const minutes = Math.round(STALL_AFTER_MS / 60_000);
91
+ process.stderr.write(`[reticle] no app has connected after ${String(minutes)} minutes — ` +
92
+ 'the install may be incomplete. Restart the dev server if you added the Reticle plugin.\n');
93
+ }
89
94
  if (metrics.empty)
90
95
  return; // an idle window is not worth an event
91
96
  // NOT daemon_stopped: the daemon is still running. See SESSION_PROGRESS.
@@ -46,5 +46,12 @@ export interface StallFacts {
46
46
  export declare function reportInstrumentationStall(facts: StallFacts, now?: () => number, opts?: {
47
47
  atShutdown?: boolean;
48
48
  }): boolean;
49
+ /**
50
+ * How long this daemon has waited with no app, or undefined if the question is moot.
51
+ *
52
+ * Moot means: the clock was never started, or an app already connected. Callers use this to
53
+ * surface the stall to user-facing channels without coupling to the telemetry event itself.
54
+ */
55
+ export declare function stallUptime(now: number): number | undefined;
49
56
  /** Tests only. */
50
57
  export declare function resetInstrumentationStall(): void;
@@ -83,6 +83,19 @@ export function reportInstrumentationStall(facts, now = () => Date.now(), opts =
83
83
  }
84
84
  return true;
85
85
  }
86
+ /**
87
+ * How long this daemon has waited with no app, or undefined if the question is moot.
88
+ *
89
+ * Moot means: the clock was never started, or an app already connected. Callers use this to
90
+ * surface the stall to user-facing channels without coupling to the telemetry event itself.
91
+ */
92
+ export function stallUptime(now) {
93
+ if (daemonStartedAt === undefined)
94
+ return undefined;
95
+ if (appEverConnected())
96
+ return undefined;
97
+ return now - daemonStartedAt;
98
+ }
86
99
  /** Tests only. */
87
100
  export function resetInstrumentationStall() {
88
101
  reported = false;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Enterprise activation, as the three facts telemetry reports on every event.
3
+ *
4
+ * WHY IT IS ON EVERY EVENT, not just an activation one: the questions a licensed customer generates
5
+ * are "how much is this org using it", "what is breaking for them", "did their key lapse" — and every
6
+ * one of those is answered by an event that has nothing to do with licensing. A status that rode only
7
+ * its own event would tell us a key verified once and nothing about the sessions it covered. Same
8
+ * reasoning that moved `installSource` onto every event.
9
+ *
10
+ * WHY STATUS IS SEPARATE FROM IDENTITY: `licenseId` is only present while a key verifies, so on
11
+ * identity alone a lapsed customer and a departed one are the same silence. `licenseStatus` keeps
12
+ * reporting through `expired` and `invalid`, which is the difference between seeing a renewal coming
13
+ * and hearing about it from the customer.
14
+ *
15
+ * WHAT IS DELIBERATELY ABSENT: the organisation NAME, and the PLAN. The name is free text somebody
16
+ * typed at signing time, which rule 3 forbids outright. The plan is merely redundant, which is a
17
+ * quieter reason to leave something out and an easy one to lose sight of: it is keyed by this same
18
+ * `lid` in the issuance ledger, so sending it on every event from every machine forever pays a
19
+ * per-event cost to carry what one local file already holds, and the join that turns a `lid` into a
20
+ * company turns it into a plan at the same moment. `reticle license` still reports it locally, where
21
+ * it costs nothing and answers a question the operator is actually asking.
22
+ *
23
+ * More on the name: It is free text somebody typed at signing time,
24
+ * and rule 3 of the telemetry contract is names-never-values. `licenseId` is an opaque uuid that
25
+ * resolves to a company only against the issuance ledger held locally, so the analytics backend never
26
+ * holds a customer list.
27
+ */
28
+ import { LicenseActivation } from '@reticlehq/core';
29
+ /** The activation facts that ride the wire. All absent on a build with no issuer key baked. */
30
+ export interface LicenseFacts {
31
+ licenseId?: string;
32
+ licenseStatus?: LicenseActivation;
33
+ }
34
+ /**
35
+ * Resolve activation for one event. The clock is passed in (rule 7) and is the EVENT's clock, not a
36
+ * value captured at startup: sessions here run to eleven hours, so a key that expires mid-session must
37
+ * start reporting `expired` from the event it expired on rather than the whole run inheriting the
38
+ * status it had at boot.
39
+ *
40
+ * `eval` reports NOTHING. It means no issuer key is baked at all, which is every OSS install and every
41
+ * source checkout, so emitting it would add three properties to the dominant population to say "not a
42
+ * licensed build" — which absence already says, more cheaply. A mis-built production release resolves
43
+ * to `invalid` rather than `eval` and IS reported, because that one we need to hear about.
44
+ *
45
+ * Never throws. A telemetry property may not change behaviour (rule 5), and this one sits in front of
46
+ * key parsing, which is the part most likely to be handed something malformed.
47
+ */
48
+ export declare function licenseFacts(now: number, env?: NodeJS.ProcessEnv): LicenseFacts;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Enterprise activation, as the three facts telemetry reports on every event.
3
+ *
4
+ * WHY IT IS ON EVERY EVENT, not just an activation one: the questions a licensed customer generates
5
+ * are "how much is this org using it", "what is breaking for them", "did their key lapse" — and every
6
+ * one of those is answered by an event that has nothing to do with licensing. A status that rode only
7
+ * its own event would tell us a key verified once and nothing about the sessions it covered. Same
8
+ * reasoning that moved `installSource` onto every event.
9
+ *
10
+ * WHY STATUS IS SEPARATE FROM IDENTITY: `licenseId` is only present while a key verifies, so on
11
+ * identity alone a lapsed customer and a departed one are the same silence. `licenseStatus` keeps
12
+ * reporting through `expired` and `invalid`, which is the difference between seeing a renewal coming
13
+ * and hearing about it from the customer.
14
+ *
15
+ * WHAT IS DELIBERATELY ABSENT: the organisation NAME, and the PLAN. The name is free text somebody
16
+ * typed at signing time, which rule 3 forbids outright. The plan is merely redundant, which is a
17
+ * quieter reason to leave something out and an easy one to lose sight of: it is keyed by this same
18
+ * `lid` in the issuance ledger, so sending it on every event from every machine forever pays a
19
+ * per-event cost to carry what one local file already holds, and the join that turns a `lid` into a
20
+ * company turns it into a plan at the same moment. `reticle license` still reports it locally, where
21
+ * it costs nothing and answers a question the operator is actually asking.
22
+ *
23
+ * More on the name: It is free text somebody typed at signing time,
24
+ * and rule 3 of the telemetry contract is names-never-values. `licenseId` is an opaque uuid that
25
+ * resolves to a company only against the issuance ledger held locally, so the analytics backend never
26
+ * holds a customer list.
27
+ */
28
+ import { LicenseActivation } from '@reticlehq/core';
29
+ import { describeLicense } from '../license/license.js';
30
+ /**
31
+ * Resolve activation for one event. The clock is passed in (rule 7) and is the EVENT's clock, not a
32
+ * value captured at startup: sessions here run to eleven hours, so a key that expires mid-session must
33
+ * start reporting `expired` from the event it expired on rather than the whole run inheriting the
34
+ * status it had at boot.
35
+ *
36
+ * `eval` reports NOTHING. It means no issuer key is baked at all, which is every OSS install and every
37
+ * source checkout, so emitting it would add three properties to the dominant population to say "not a
38
+ * licensed build" — which absence already says, more cheaply. A mis-built production release resolves
39
+ * to `invalid` rather than `eval` and IS reported, because that one we need to hear about.
40
+ *
41
+ * Never throws. A telemetry property may not change behaviour (rule 5), and this one sits in front of
42
+ * key parsing, which is the part most likely to be handed something malformed.
43
+ */
44
+ export function licenseFacts(now, env = process.env) {
45
+ try {
46
+ const report = describeLicense(now, env);
47
+ if (LicenseActivation.EVAL === report.status)
48
+ return {};
49
+ return {
50
+ licenseStatus: report.status,
51
+ ...(report.licenseId !== undefined ? { licenseId: report.licenseId } : {}),
52
+ };
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
@@ -71,6 +71,7 @@ const DETACHED_SEND_SCRIPT = "fetch(process.argv[1],{method:'POST',headers:{'con
71
71
  /** Env var names — mirror cloud-sync's `RETICLE_*` convention. */
72
72
  import { isReticleSourceCheckout } from './dev-repo.js';
73
73
  import { projectInstallSource } from './install-source.js';
74
+ import { licenseFacts } from './license-activation.js';
74
75
  import { gitFacts } from './git-facts.js';
75
76
  import { currentAutomationHint } from './automation-hint.js';
76
77
  const Env = {
@@ -302,13 +303,16 @@ export const createTelemetry = (opts) => {
302
303
  // feedback, and no metric is worth phoning home from a contributor's clone.
303
304
  if (feedbackOnly && TelemetryEventKind.FEEDBACK_SUBMITTED !== kind)
304
305
  return false;
306
+ // One clock read for the whole event: `ts` and the licence check must agree, and two calls to
307
+ // now() can straddle an expiry.
308
+ const eventTs = now();
305
309
  const event = {
306
310
  v: TELEMETRY_EVENT_VERSION,
307
311
  anonymousId,
308
312
  sessionId,
309
313
  projectId,
310
314
  event: kind,
311
- ts: now(),
315
+ ts: eventTs,
312
316
  version: opts.version,
313
317
  ci,
314
318
  // A SCALAR, so it needs the event build and the wire schema and no `blocks` entry — the same
@@ -340,6 +344,11 @@ export const createTelemetry = (opts) => {
340
344
  // Always present now, rather than only when a caller remembered to pass it. A per-call
341
345
  // override still wins so a command that genuinely knows better can say so.
342
346
  installSource: extra?.installSource ?? installSource,
347
+ // Enterprise activation: three SCALARS, so they need the event build and the wire schema and no
348
+ // `blocks` entry. Resolved per event off the event's own clock rather than once at startup — an
349
+ // eleven-hour session must not report the status it booted with. All three are absent unless a
350
+ // release has an issuer key baked, so an OSS payload is byte-identical to before.
351
+ ...licenseFacts(eventTs),
343
352
  };
344
353
  // Map the core contract onto PostHog's capture shape: id/name/time move up, the rest are properties.
345
354
  // The feedback body is FLATTENED into `feedback_*` properties rather than sent as a nested object:
@@ -75,7 +75,7 @@ export function buildDynamicTools(allTools, profile) {
75
75
  };
76
76
  const reticleTools = {
77
77
  name: ReticleTool.TOOLS,
78
- description: 'Discover Reticle tools on demand. Call with no arguments to list every tool (name + one-line summary); call with names:["reticle_network", …] to load full descriptions and parameters for specific tools. Then invoke them with reticle_run. This avoids paying for every tool definition on every turn. To make a verification REUSABLE (record once, replay free forever), the flow workflow lives here: reticle_record{action:"start"} → act → reticle_flow_save → reticle_flow_verify (and reticle_flow_heal on drift). Load those names when you want to save or re-run a flow.',
78
+ description: 'Discover Reticle tools on demand. Call with no arguments to list every tool (name + one-line summary); call with names:["reticle_network", …] to load full descriptions and parameters for specific tools. Then invoke them with reticle_run. This avoids paying for every tool definition on every turn. To make a verification REUSABLE (record once, replay free forever), the flow workflow lives here: reticle_record{action:"start"} → act → reticle_flow_save → reticle_verify{action:"flows"} (and reticle_flow_heal on drift). Load those names when you want to save or re-run a flow.',
79
79
  inputSchema: {
80
80
  names: z
81
81
  .array(z.string())
@@ -129,7 +129,7 @@ export const RECOVERY = {
129
129
  'synchronous task, an alert/confirm dialog); reticle_console usually shows what it hit. Retry ' +
130
130
  'once the cause is addressed.',
131
131
  FLOW_STEP_MISSING: 'A recorded step points at an element that is not on the page now. Either the app changed under ' +
132
- 'the flow — run reticle_flow_heal to re-anchor it, then reticle_flow_verify — or an earlier step ' +
132
+ 'the flow — run reticle_flow_heal to re-anchor it, then reticle_verify{action:"flows"} — or an earlier step ' +
133
133
  'did not do what it used to, leaving the run on the wrong page: check reticle_snapshot to see ' +
134
134
  'where it actually is. This is app/flow drift, which is exactly what the flow exists to catch.',
135
135
  NO_POOL: 'Reticle cannot launch its own browser here: the daemon-managed pool is absent, or Playwright ' +