@reticlehq/server 2.5.0 → 2.6.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 (147) hide show
  1. package/SKILL.md +27 -10
  2. package/dist/bridge/bridge.d.ts +12 -0
  3. package/dist/bridge/bridge.js +32 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/cli-doctor.d.ts +0 -5
  7. package/dist/cli/cli-doctor.js +71 -3
  8. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  9. package/dist/cli/cli-parse-feedback.js +56 -0
  10. package/dist/cli/cli-parse.d.ts +5 -9
  11. package/dist/cli/cli-parse.js +7 -23
  12. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  13. package/dist/cli/doctor-daemon-line.js +35 -0
  14. package/dist/cli/port-holder.d.ts +43 -0
  15. package/dist/cli/port-holder.js +99 -0
  16. package/dist/cli.js +131 -11
  17. package/dist/daemon/daemon-resilience.d.ts +24 -0
  18. package/dist/daemon/daemon-resilience.js +105 -17
  19. package/dist/daemon/daemon.js +26 -1
  20. package/dist/daemon/heartbeat.d.ts +115 -0
  21. package/dist/daemon/heartbeat.js +141 -0
  22. package/dist/daemon/idle-shutdown.d.ts +13 -5
  23. package/dist/daemon/idle-shutdown.js +14 -6
  24. package/dist/daemon/port-presence.d.ts +76 -0
  25. package/dist/daemon/port-presence.js +93 -0
  26. package/dist/daemon/state-dir.d.ts +7 -0
  27. package/dist/daemon/state-dir.js +59 -0
  28. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  29. package/dist/daemon/unclean-predecessor.js +27 -0
  30. package/dist/events/predicate-eval.d.ts +10 -0
  31. package/dist/events/predicate-eval.js +24 -4
  32. package/dist/events/predicate-parse.d.ts +23 -0
  33. package/dist/events/predicate-parse.js +54 -0
  34. package/dist/events/predicate.d.ts +5 -0
  35. package/dist/events/predicate.js +17 -3
  36. package/dist/events/ring-buffer.js +1 -16
  37. package/dist/flows/flow-replay.d.ts +1 -1
  38. package/dist/flows/flow-replay.js +19 -2
  39. package/dist/flows/flow-step-runners.js +2 -3
  40. package/dist/flows/flow-tools.js +10 -14
  41. package/dist/flows/role-drift-reason.d.ts +20 -0
  42. package/dist/flows/role-drift-reason.js +39 -0
  43. package/dist/flows/suite-flakes.d.ts +33 -0
  44. package/dist/flows/suite-flakes.js +32 -0
  45. package/dist/honesty/blind-spots.d.ts +19 -0
  46. package/dist/honesty/blind-spots.js +33 -0
  47. package/dist/honesty/verified.d.ts +10 -1
  48. package/dist/honesty/verified.js +16 -0
  49. package/dist/http-server.js +9 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +10 -1
  52. package/dist/init/agent-rules.d.ts +18 -0
  53. package/dist/init/agent-rules.js +22 -4
  54. package/dist/init/cra.d.ts +13 -0
  55. package/dist/init/cra.js +16 -0
  56. package/dist/init/detect.d.ts +6 -0
  57. package/dist/init/detect.js +8 -0
  58. package/dist/init/mcp-clients.d.ts +81 -0
  59. package/dist/init/mcp-clients.js +248 -0
  60. package/dist/init/node-io.js +8 -2
  61. package/dist/init/plan-framework.js +36 -2
  62. package/dist/init/plan.d.ts +14 -0
  63. package/dist/init/plan.js +73 -9
  64. package/dist/init/run.d.ts +18 -1
  65. package/dist/init/run.js +133 -16
  66. package/dist/init/windows-quote.d.ts +38 -0
  67. package/dist/init/windows-quote.js +70 -0
  68. package/dist/mcp/mcp-outage.d.ts +1 -0
  69. package/dist/mcp/mcp-outage.js +8 -1
  70. package/dist/mcp/mcp-proxy.d.ts +11 -1
  71. package/dist/mcp/mcp-proxy.js +30 -7
  72. package/dist/mcp/mcp.js +29 -2
  73. package/dist/pool/browser-pool.d.ts +9 -0
  74. package/dist/pool/browser-pool.js +38 -9
  75. package/dist/session/attachment-history.d.ts +46 -0
  76. package/dist/session/attachment-history.js +65 -0
  77. package/dist/session/no-session-diagnosis.d.ts +7 -0
  78. package/dist/session/no-session-diagnosis.js +53 -3
  79. package/dist/session/no-session-watch.d.ts +3 -1
  80. package/dist/session/no-session-watch.js +10 -2
  81. package/dist/session/pending-commands.js +1 -0
  82. package/dist/session/session-info.d.ts +17 -1
  83. package/dist/session/session-info.js +5 -1
  84. package/dist/session/session-manager.d.ts +3 -0
  85. package/dist/session/session-manager.js +72 -3
  86. package/dist/session/session.d.ts +1 -0
  87. package/dist/session/session.js +8 -0
  88. package/dist/telemetry/crash-cause.d.ts +47 -0
  89. package/dist/telemetry/crash-cause.js +104 -0
  90. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  91. package/dist/telemetry/daemon-telemetry.js +28 -13
  92. package/dist/telemetry/error-class.d.ts +26 -0
  93. package/dist/telemetry/error-class.js +52 -0
  94. package/dist/telemetry/feedback-cli.d.ts +7 -2
  95. package/dist/telemetry/feedback-cli.js +49 -5
  96. package/dist/telemetry/feedback-context.d.ts +1 -6
  97. package/dist/telemetry/feedback-context.js +64 -4
  98. package/dist/telemetry/git-facts.d.ts +7 -0
  99. package/dist/telemetry/git-facts.js +4 -4
  100. package/dist/telemetry/project-profile.js +2 -1
  101. package/dist/telemetry/session-metrics.d.ts +45 -3
  102. package/dist/telemetry/session-metrics.js +247 -9
  103. package/dist/telemetry/telemetry.d.ts +19 -1
  104. package/dist/telemetry/telemetry.js +31 -8
  105. package/dist/tools/act-tools.js +33 -6
  106. package/dist/tools/assert-verdict.d.ts +7 -1
  107. package/dist/tools/assert-verdict.js +15 -3
  108. package/dist/tools/browser-tools.js +8 -2
  109. package/dist/tools/contract-tools.d.ts +0 -5
  110. package/dist/tools/contract-tools.js +26 -0
  111. package/dist/tools/dynamic-tools.js +6 -1
  112. package/dist/tools/error-recovery.d.ts +14 -0
  113. package/dist/tools/error-recovery.js +83 -7
  114. package/dist/tools/feedback-invite.d.ts +48 -0
  115. package/dist/tools/feedback-invite.js +71 -0
  116. package/dist/tools/invoke-tool.d.ts +0 -6
  117. package/dist/tools/invoke-tool.js +69 -1
  118. package/dist/tools/navigate-arrival.d.ts +26 -0
  119. package/dist/tools/navigate-arrival.js +60 -0
  120. package/dist/tools/navigate-result.d.ts +15 -1
  121. package/dist/tools/navigate-result.js +17 -10
  122. package/dist/tools/observe-tools.js +22 -4
  123. package/dist/tools/real-input-attempt.js +10 -5
  124. package/dist/tools/tool-kit.d.ts +8 -0
  125. package/dist/tools/tool-surface.js +13 -0
  126. package/docs/README.md +55 -0
  127. package/docs/agent-cheatsheet.md +2 -2
  128. package/docs/benchmarks.md +1 -1
  129. package/docs/fixtures-dispatch-receiver.yml +76 -0
  130. package/docs/fixtures.md +17 -0
  131. package/docs/gate-plan.md +98 -0
  132. package/docs/gates.md +117 -0
  133. package/docs/local-registry.md +1 -1
  134. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  135. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  136. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  137. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  138. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  139. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  140. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  141. package/docs/matrix/MATRIX.md +26 -0
  142. package/docs/matrix/README.md +59 -0
  143. package/docs/mint.json +1 -1
  144. package/docs/system-map.md +152 -0
  145. package/docs/telemetry-contract.md +2 -2
  146. package/docs/telemetry.md +7 -0
  147. package/package.json +2 -2
@@ -373,8 +373,21 @@ function postToSession(url, body) {
373
373
  req.end();
374
374
  });
375
375
  }
376
+ /**
377
+ * Turn an `endpoint` frame's data into a POST target, or null when there is nothing usable in it.
378
+ *
379
+ * An empty (or whitespace) data field is the shape a malformed daemon response takes, and it used to
380
+ * be accepted: `postUrl` became `''`, which is not `null`, so the proxy considered itself CONNECTED
381
+ * and posted every subsequent message to the empty string. Each one failed, the queue never armed
382
+ * its deadline because nothing was ever queued, and the agent held a session that could not carry a
383
+ * single call. Refusing the frame keeps the proxy in its disconnected path, which already knows how
384
+ * to reconnect and how to answer what it cannot send.
385
+ */
376
386
  export function buildSessionUrl(rawData, port) {
377
- return rawData.startsWith('/') ? `http://${LOOPBACK_HOST}:${port}${rawData}` : rawData;
387
+ const trimmed = rawData.trim();
388
+ if ('' === trimmed)
389
+ return null;
390
+ return trimmed.startsWith('/') ? `http://${LOOPBACK_HOST}:${port}${trimmed}` : trimmed;
378
391
  }
379
392
  /**
380
393
  * Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
@@ -469,6 +482,13 @@ ensureDaemon) {
469
482
  function onSseEvent(event, data, p) {
470
483
  if ('endpoint' === event) {
471
484
  const url = buildSessionUrl(data, p);
485
+ if (null === url) {
486
+ proxyLog('reticle_mcp_proxy_empty_endpoint', {
487
+ port,
488
+ note: 'endpoint frame carried no URL; staying disconnected rather than posting to nowhere',
489
+ });
490
+ return;
491
+ }
472
492
  postUrl = url;
473
493
  // A stream that reached `endpoint` is a USABLE session — that, not a set of response
474
494
  // headers, is what earns a fresh retry budget. Resetting on headers let a daemon that
@@ -491,18 +511,19 @@ ensureDaemon) {
491
511
  emit(data);
492
512
  }
493
513
  }
494
- function scheduleReconnect(reason, detail) {
514
+ function scheduleReconnect(reason, detail, pendingLost = 0) {
495
515
  if (stopped)
496
516
  return;
497
517
  postUrl = null;
498
518
  attempts++;
499
519
  // Once per process: this session has now lost its tools at least once, which is the number
500
- // that says whether the transport work actually landed for real users.
501
- reportMcpOutage(OutageStage.FIRST, { reason, attempts });
520
+ // that says whether the transport work actually landed for real users. `pendingLost` is the
521
+ // part an agent can FEEL — 320 of 321 measured drops killed nothing in flight.
522
+ reportMcpOutage(OutageStage.FIRST, { reason, attempts, pendingLost });
502
523
  // The severe stage: we are about to stop retrying. Reported so the share of sessions where
503
524
  // MCP never came back on its own is a number rather than an anecdote.
504
525
  if (attempts > MAX_RECONNECT_ATTEMPTS) {
505
- reportMcpOutage(OutageStage.BUDGET_SPENT, { reason, attempts });
526
+ reportMcpOutage(OutageStage.BUDGET_SPENT, { reason, attempts, pendingLost });
506
527
  // Stop RETRYING, never stop SERVING. See onReconnectBudgetSpent: exiting here made the
507
528
  // client mark the MCP server disconnected and left a human to reconnect it by hand.
508
529
  dormant = onReconnectBudgetSpent() === OnDrop.DORMANT;
@@ -557,9 +578,11 @@ ensureDaemon) {
557
578
  settled = true;
558
579
  // Answer anything the dead session owed BEFORE reconnecting. The new session cannot know
559
580
  // about it, so silence here is permanent.
560
- for (const reply of streamLossReplies(pending, reason))
581
+ const lost = streamLossReplies(pending, reason);
582
+ for (const reply of lost)
561
583
  emit(reply);
562
- scheduleReconnect(reason, detail);
584
+ // How many calls this drop actually killed. Zero means the agent could not feel it.
585
+ scheduleReconnect(reason, detail, lost.length);
563
586
  };
564
587
  const sse = new SseFrameParser();
565
588
  res.on('data', (chunk) => {
package/dist/mcp/mcp.js CHANGED
@@ -30,6 +30,27 @@ export function withSessionEnvelope(name, outputSchema) {
30
30
  return { ...sessionEnvelopeShape, ...outputSchema };
31
31
  }
32
32
  const SERVER_INFO = { name: MCP_SERVER_NAME, version: SERVER_VERSION };
33
+ /**
34
+ * What every agent is told at `initialize`, before it has called a single tool.
35
+ *
36
+ * This is the only channel that reaches an agent with NO dependency on the project: no CLAUDE.md, no
37
+ * AGENTS.md, no pasted skill, no successful `reticle init`. An agent whose setup half-worked has read
38
+ * none of those files and is precisely the agent whose report we need — so the standing instruction
39
+ * to send feedback has to live here, where it arrives with the connection itself.
40
+ *
41
+ * Kept to a handful of lines: it is prepended to every session's context, so each sentence is paid
42
+ * for on every turn, forever.
43
+ */
44
+ const SERVER_INSTRUCTIONS = `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.
45
+
46
+ 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.
47
+
48
+ Feedback is first-class here, not a courtesy. Reticle is built FOR agents, so your experience is the only signal that decides what gets built or fixed — and it disappears when your context does. Send it with \`reticle_feedback\` the moment you notice, then carry on with your task:
49
+ - Reticle broke, lied, or could not see something you needed (\`bug\` / \`gap\` / \`ambiguity\`).
50
+ - You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
51
+ - The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
52
+
53
+ 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.`;
33
54
  /** First sentence of a description (purpose only) for lean profiles — keeps per-turn def cost
34
55
  * down. Cuts at the first sentence-ending period or newline; falls back to a 160-char cap. The
35
56
  * first clause retains the essentials (enum hints like "role | text | label" live there). */
@@ -292,7 +313,11 @@ function toolNameOf(request) {
292
313
  }
293
314
  export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
294
315
  const encoding = (process.env[ENCODING_ENV] ?? '').toLowerCase();
295
- const server = new McpServer(SERVER_INFO);
316
+ // Which surface this daemon advertises. The 18-tool default and the 48-tool full surface are
317
+ // different products from inside an agent's context, so outcomes are only comparable when the
318
+ // session says which one it saw.
319
+ getSessionMetrics().recordSurface(profile);
320
+ const server = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
296
321
  // Which agent is on the other end, taken from its own `initialize` handshake. Registered as a lazy
297
322
  // hook rather than read here: the handshake has not happened yet at construction time, and a
298
323
  // feedback report filed twenty tool calls later is exactly when we want the answer.
@@ -300,8 +325,10 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
300
325
  const info = server.server.getClientVersion();
301
326
  // Record it as a side effect of the first read so the session summary can report WHICH agents
302
327
  // drove this daemon — the multi-agent story, visible without a second plumbing path.
328
+ // Name AND version: the version was already in this handshake and was being dropped, so a
329
+ // regression in agent behaviour could never be attributed to a client build.
303
330
  if (info?.name !== undefined)
304
- getSessionMetrics().recordClient(info.name);
331
+ getSessionMetrics().recordClient(info.name, info.version);
305
332
  return info === undefined ? undefined : { name: info.name, version: info.version };
306
333
  });
307
334
  // Cast once to our bridge type so every per-tool call site is typed without `any`.
@@ -77,6 +77,15 @@ export declare class BrowserPool {
77
77
  * forever. Returns the released sessionIds. This is the fault-tolerance backstop for the pool.
78
78
  */
79
79
  sweepExpired(): Promise<string[]>;
80
+ /**
81
+ * How many leases this pool has reclaimed for going stale.
82
+ *
83
+ * Read by the no-session diagnosis: an aged-out lease used to be reported as "the tab was closed
84
+ * … ask the human to reopen the app", which is wrong on every clause and sent one reporter looking
85
+ * for a port mismatch (#157). A count is enough — the message says "likeliest", not "certainly",
86
+ * because nothing here knows WHICH session the caller just lost.
87
+ */
88
+ reapedLeaseCount(): number;
80
89
  /**
81
90
  * Lease an isolated context, navigate it to `url`, and return a handle. If the pool is at capacity,
82
91
  * waits FIFO until a slot frees (or until `signal` aborts, if provided).
@@ -27,6 +27,8 @@ export class BrowserPool {
27
27
  #browser;
28
28
  #launching;
29
29
  #closed = false;
30
+ /** Leases reclaimed for going stale — see reapedLeaseCount(). */
31
+ #reapedLeases = 0;
30
32
  #active = new Map();
31
33
  /**
32
34
  * Slots claimed-or-active — the real concurrency gate. Incremented SYNCHRONOUSLY the instant an
@@ -85,8 +87,20 @@ export class BrowserPool {
85
87
  .map(([sessionId]) => sessionId);
86
88
  for (const sessionId of expired)
87
89
  await this.#release(sessionId);
90
+ this.#reapedLeases += expired.length;
88
91
  return expired;
89
92
  }
93
+ /**
94
+ * How many leases this pool has reclaimed for going stale.
95
+ *
96
+ * Read by the no-session diagnosis: an aged-out lease used to be reported as "the tab was closed
97
+ * … ask the human to reopen the app", which is wrong on every clause and sent one reporter looking
98
+ * for a port mismatch (#157). A count is enough — the message says "likeliest", not "certainly",
99
+ * because nothing here knows WHICH session the caller just lost.
100
+ */
101
+ reapedLeaseCount() {
102
+ return this.#reapedLeases;
103
+ }
90
104
  /**
91
105
  * Lease an isolated context, navigate it to `url`, and return a handle. If the pool is at capacity,
92
106
  * waits FIFO until a slot frees (or until `signal` aborts, if provided).
@@ -138,6 +152,15 @@ export class BrowserPool {
138
152
  this.#active.clear();
139
153
  this.#occupied = 0;
140
154
  await Promise.all(contexts.map((c) => c.close().catch(() => undefined)));
155
+ // If a launch is in flight, await it so we can close the resulting browser — otherwise it
156
+ // resolves after shutdown returns and the Chromium process is orphaned (hundreds of MB leaked).
157
+ const inflight = this.#launching;
158
+ if (inflight !== undefined) {
159
+ const launched = await inflight.catch(() => undefined);
160
+ if (launched !== undefined && launched !== this.#browser) {
161
+ await launched.close().catch(() => undefined);
162
+ }
163
+ }
141
164
  const browser = this.#browser;
142
165
  this.#browser = undefined;
143
166
  if (browser !== undefined)
@@ -168,27 +191,33 @@ export class BrowserPool {
168
191
  const onFree = () => {
169
192
  // The pool shut down while we waited → reject rather than relaunch a browser.
170
193
  if (this.#closed) {
194
+ if (signal !== undefined)
195
+ signal.removeEventListener('abort', onAbort);
171
196
  reject(new Error('browser pool is shut down'));
172
197
  return;
173
198
  }
174
199
  // Claim the freed slot; if another woken waiter beat us to it, re-queue.
175
- if (this.#tryClaim())
200
+ if (this.#tryClaim()) {
201
+ if (signal !== undefined)
202
+ signal.removeEventListener('abort', onAbort);
176
203
  resolve();
177
- else
204
+ }
205
+ else {
178
206
  this.#waiters.push(onFree);
207
+ }
208
+ };
209
+ const onAbort = () => {
210
+ const i = this.#waiters.indexOf(onFree);
211
+ if (i >= 0)
212
+ this.#waiters.splice(i, 1);
213
+ reject(new Error('acquire aborted'));
179
214
  };
180
215
  if (signal !== undefined) {
181
216
  if (signal.aborted) {
182
217
  reject(new Error('acquire aborted'));
183
218
  return;
184
219
  }
185
- signal.addEventListener('abort', () => {
186
- // Remove ourselves from the queue so a later wake doesn't claim a slot we'll never use.
187
- const i = this.#waiters.indexOf(onFree);
188
- if (i >= 0)
189
- this.#waiters.splice(i, 1);
190
- reject(new Error('acquire aborted'));
191
- }, { once: true });
220
+ signal.addEventListener('abort', onAbort, { once: true });
192
221
  }
193
222
  this.#waiters.push(onFree);
194
223
  });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Whether a session was attached for the whole window an agent is about to reason about.
3
+ *
4
+ * `reticle_sessions` answers "which tabs are connected RIGHT NOW", and **a tab that dropped for four
5
+ * seconds and came back looks identical to one that never dropped** (#117). That is the difference
6
+ * between a verdict you can trust and one that describes what you happened to see — the same problem
7
+ * `honesty.integrity` solves for a single action, applied to the session.
8
+ *
9
+ * Measured HERE rather than reported by the SDK. The browser transport does track
10
+ * `#disconnectedSince` (`transport.ts:95`), and reading it would need a wire change and a new thing
11
+ * for every SDK version to remember to send. The daemon already sees both halves — `remove()` when
12
+ * the socket drops, `add()` when it returns — so the gap is computable with no protocol change at
13
+ * all, and it cannot drift out of sync with what the daemon actually observed.
14
+ *
15
+ * The clock is injected, per the repo rule: this is arithmetic over timestamps, and a test that
16
+ * needs to wait four seconds to check a four-second gap is a test nobody runs.
17
+ */
18
+ /** What an agent needs to know about the continuity of one session. */
19
+ export interface Attachment {
20
+ /** How long THIS attachment has lasted. Resets on reconnect — it is not the session's lifetime. */
21
+ connectedSinceMs: number;
22
+ /** How many times this session dropped and came back. Zero means it never left. */
23
+ outages: number;
24
+ /** The most recent gap, when there has been one. */
25
+ lastOutage?: {
26
+ startedMs: number;
27
+ durationMs: number;
28
+ };
29
+ }
30
+ export declare class AttachmentHistory {
31
+ #private;
32
+ constructor(now?: () => number);
33
+ /** A session connected, or reconnected. A reconnect closes the open gap and counts an outage. */
34
+ attached(sessionId: string): void;
35
+ /** A session dropped. The gap stays open until it comes back, or the record is forgotten. */
36
+ detached(sessionId: string): void;
37
+ /** Drop a session's history. A long-lived daemon must not accumulate one record per tab forever. */
38
+ forget(sessionId: string): void;
39
+ /**
40
+ * The continuity of one session, or `undefined` when it has never been seen.
41
+ *
42
+ * Undefined rather than a zeroed record on purpose: `outages: 0` reads as "it never dropped", which
43
+ * is a confident claim about a session this has no knowledge of.
44
+ */
45
+ of(sessionId: string): Attachment | undefined;
46
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Whether a session was attached for the whole window an agent is about to reason about.
3
+ *
4
+ * `reticle_sessions` answers "which tabs are connected RIGHT NOW", and **a tab that dropped for four
5
+ * seconds and came back looks identical to one that never dropped** (#117). That is the difference
6
+ * between a verdict you can trust and one that describes what you happened to see — the same problem
7
+ * `honesty.integrity` solves for a single action, applied to the session.
8
+ *
9
+ * Measured HERE rather than reported by the SDK. The browser transport does track
10
+ * `#disconnectedSince` (`transport.ts:95`), and reading it would need a wire change and a new thing
11
+ * for every SDK version to remember to send. The daemon already sees both halves — `remove()` when
12
+ * the socket drops, `add()` when it returns — so the gap is computable with no protocol change at
13
+ * all, and it cannot drift out of sync with what the daemon actually observed.
14
+ *
15
+ * The clock is injected, per the repo rule: this is arithmetic over timestamps, and a test that
16
+ * needs to wait four seconds to check a four-second gap is a test nobody runs.
17
+ */
18
+ export class AttachmentHistory {
19
+ #now;
20
+ #records = new Map();
21
+ constructor(now = Date.now) {
22
+ this.#now = now;
23
+ }
24
+ /** A session connected, or reconnected. A reconnect closes the open gap and counts an outage. */
25
+ attached(sessionId) {
26
+ const at = this.#now();
27
+ const prior = this.#records.get(sessionId);
28
+ if (prior?.detachedAt === undefined) {
29
+ this.#records.set(sessionId, { attachedAt: at, outages: prior?.outages ?? 0 });
30
+ return;
31
+ }
32
+ this.#records.set(sessionId, {
33
+ attachedAt: at,
34
+ outages: prior.outages + 1,
35
+ lastOutage: { startedMs: prior.detachedAt, durationMs: at - prior.detachedAt },
36
+ });
37
+ }
38
+ /** A session dropped. The gap stays open until it comes back, or the record is forgotten. */
39
+ detached(sessionId) {
40
+ const prior = this.#records.get(sessionId);
41
+ if (prior === undefined)
42
+ return;
43
+ this.#records.set(sessionId, { ...prior, detachedAt: this.#now() });
44
+ }
45
+ /** Drop a session's history. A long-lived daemon must not accumulate one record per tab forever. */
46
+ forget(sessionId) {
47
+ this.#records.delete(sessionId);
48
+ }
49
+ /**
50
+ * The continuity of one session, or `undefined` when it has never been seen.
51
+ *
52
+ * Undefined rather than a zeroed record on purpose: `outages: 0` reads as "it never dropped", which
53
+ * is a confident claim about a session this has no knowledge of.
54
+ */
55
+ of(sessionId) {
56
+ const record = this.#records.get(sessionId);
57
+ if (record === undefined)
58
+ return undefined;
59
+ return {
60
+ connectedSinceMs: this.#now() - record.attachedAt,
61
+ outages: record.outages,
62
+ ...(record.lastOutage === undefined ? {} : { lastOutage: record.lastOutage }),
63
+ };
64
+ }
65
+ }
@@ -27,6 +27,13 @@ interface NoSessionFacts {
27
27
  listening: readonly number[];
28
28
  /** The port this daemon is on — half of the mismatch the old message asked about. */
29
29
  port: number;
30
+ /**
31
+ * This daemon has reaped at least one EXPIRED pooled lease.
32
+ *
33
+ * Deliberately not "the session that just vanished was that lease" — nothing knows that. It is
34
+ * evidence, not proof, and the message below is worded accordingly.
35
+ */
36
+ leaseExpired?: boolean;
30
37
  }
31
38
  /**
32
39
  * Every branch ends with this, and `recoveryFor` keys on it to suppress the generic no-session
@@ -18,6 +18,7 @@
18
18
  * Pure: everything it needs is passed in, so the probe that discovers listening ports stays out of
19
19
  * the hot resolve() path and this stays unit-testable.
20
20
  */
21
+ import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
21
22
  /**
22
23
  * Every branch ends with this, and `recoveryFor` keys on it to suppress the generic no-session
23
24
  * recovery: a message that already carries its own next action must not be handed a second, more
@@ -42,18 +43,67 @@ const RETRY = SELF_RECOVERING_MARKER;
42
43
  const SELF_SERVE = 'You do not have to wait for the human: reticle_lease {action:"acquire", url} opens a browser ' +
43
44
  'Reticle drives itself, and returns a sessionId you can use immediately (reach it with ' +
44
45
  'reticle_run {tool:"reticle_lease"} if it is not advertised directly; release it when you finish).';
46
+ /**
47
+ * The ports the scan actually covers, rendered for the message.
48
+ *
49
+ * Derived from DEV_SERVER_PORTS rather than re-typed: a message that lists ports the scan does not
50
+ * check, or omits ones it does, is a new version of the same defect — a confident claim about
51
+ * evidence that was never gathered.
52
+ */
53
+ /**
54
+ * The second half of the answer for a project that never went through `init`.
55
+ *
56
+ * Held separately because it must appear ONLY when the project is uninstrumented — telling a wired
57
+ * project to re-run `init` sends the reader back to a step that already succeeded, which is its own
58
+ * kind of wrong answer.
59
+ */
60
+ const UNINSTRUMENTED = 'Separately: this project has not been through `reticle init`, which is what installs the SDK ' +
61
+ 'and wires it into the build — so even once the dev server is up, the app will carry no SDK and ' +
62
+ "no session will appear. Run `reticle init` in the app's directory too.";
63
+ const SCANNED_PORTS = [...DEV_SERVER_PORTS].join(', ');
45
64
  export function diagnoseNoSession(facts) {
46
65
  const { everConnected, initialized, listening, port } = facts;
47
66
  const ports = listening.join(', ');
48
67
  if (everConnected) {
68
+ // A reaped lease first, because it is the one cause we have POSITIVE evidence for. Reported
69
+ // from the field (#157): an aged-out lease produced "the tab was closed … ask the human to
70
+ // reopen the app", which is wrong on every clause — there is no human tab, and the recovery it
71
+ // names is unavailable to the caller while the one that works goes unmentioned. The reporter
72
+ // went looking for a port mismatch. Hedged rather than swapped: a human tab may ALSO have
73
+ // closed, and this does not know which session went.
74
+ if (true === facts.leaseExpired) {
75
+ return ('no browser session connected — but one WAS connected to this daemon earlier, so the wiring ' +
76
+ 'is correct. This daemon has expired at least one pooled lease, so the likeliest cause is ' +
77
+ 'that a lease you were using aged out; a lease is a headless context, not a human tab, and ' +
78
+ 'it takes its cookies with it (so an authenticated app needs signing in again). Re-acquire ' +
79
+ 'with reticle_lease {action:"acquire", url} and carry on. If you were driving a human tab ' +
80
+ `instead, it went away — reopen it or run \`reticle open\`. ${RETRY}`);
81
+ }
49
82
  return ('no browser session connected — but one WAS connected to this daemon earlier, so the wiring ' +
50
83
  'is correct. The tab was closed, navigated away, or hard-reloaded. Ask the human to reopen ' +
51
84
  `the app (or run \`reticle open\`), or reload the tab. ${SELF_SERVE} ${RETRY}`);
52
85
  }
53
86
  if (0 === listening.length) {
54
- return ('no browser session connected, and nothing is listening on any of the usual dev-server ports ' +
55
- '— so the app is almost certainly not running. This is not a Reticle wiring problem: ask the ' +
56
- `human to start their dev server (\`npm run dev\`), then open the app in a browser. ${RETRY}`);
87
+ return ('no browser session connected, and nothing is listening on the ports Reticle scans ' +
88
+ `(${SCANNED_PORTS}). The likeliest cause by far is that the dev server is not running: ask ` +
89
+ 'the human to start it (`npm run dev`), then open the app in a browser. ' +
90
+ // The caveat is here rather than omitted because the scan is NARROW, and the old sentence
91
+ // spent its confidence as though an empty result from eleven ports were proof of absence.
92
+ // Reported from a scripted drive of 2.5.0: this branch asserted the app was not running while
93
+ // it served 200 on :7699. A dev server on any other port — 4173 from `vite preview`, 1420
94
+ // from Tauri, 5175 from a second Vite, anything passed to --port — is invisible to it.
95
+ 'That scan is narrow, so it is not proof: a server on any other port is invisible to it. If ' +
96
+ // Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
97
+ // if nothing is listening there is nothing at any URL to open. Asking for the real one is the
98
+ // only move that can recover the :7699 case.
99
+ 'the app IS running, ask the human for its URL rather than assuming it is down. ' +
100
+ // BOTH facts at once when the project was never wired. This branch fires before the
101
+ // `!initialized` one, so an uninstrumented project used to be told only "start your dev
102
+ // server" — the reader starts it, calls again, and is told ONLY THEN that no app carries the
103
+ // SDK. Two round trips to learn two things the daemon knew on the first call, and this is the
104
+ // largest cohort in the funnel (#171): 77 users attached an agent and never instrumented an
105
+ // app. They have a daemon (they registered the MCP server) and no SDK anywhere.
106
+ `${initialized ? '' : UNINSTRUMENTED} ${RETRY}`);
57
107
  }
58
108
  if (!initialized) {
59
109
  return (`no browser session connected, but something IS listening on port ${ports} — so a server is ` +
@@ -16,4 +16,6 @@ import type { SessionManager } from './session-manager.js';
16
16
  * this directory has been through `reticle init` — so they belong together rather than as two
17
17
  * adjacent blocks in the bootstrap.
18
18
  */
19
- export declare function wireSessionScope(sessions: SessionManager, activeProjectId: string | undefined, port: number): () => void;
19
+ export declare function wireSessionScope(sessions: SessionManager, activeProjectId: string | undefined, port: number,
20
+ /** Reader for the pool's aged-out-lease count; omitted when this daemon runs no pool. */
21
+ reapedLeases?: () => number): () => void;
@@ -42,6 +42,7 @@ function startNoSessionWatch(options) {
42
42
  initialized: options.initialized,
43
43
  listening,
44
44
  port: options.port,
45
+ leaseExpired: (options.reapedLeases?.() ?? 0) > 0,
45
46
  }));
46
47
  return () => {
47
48
  clearInterval(timer);
@@ -54,8 +55,15 @@ function startNoSessionWatch(options) {
54
55
  * this directory has been through `reticle init` — so they belong together rather than as two
55
56
  * adjacent blocks in the bootstrap.
56
57
  */
57
- export function wireSessionScope(sessions, activeProjectId, port) {
58
+ export function wireSessionScope(sessions, activeProjectId, port,
59
+ /** Reader for the pool's aged-out-lease count; omitted when this daemon runs no pool. */
60
+ reapedLeases) {
58
61
  if (activeProjectId !== undefined)
59
62
  sessions.setDefaultScope({ projectId: activeProjectId });
60
- return startNoSessionWatch({ sessions, port, initialized: activeProjectId !== undefined });
63
+ return startNoSessionWatch({
64
+ sessions,
65
+ port,
66
+ initialized: activeProjectId !== undefined,
67
+ ...(reapedLeases === undefined ? {} : { reapedLeases }),
68
+ });
61
69
  }
@@ -13,6 +13,7 @@ export class PendingCommands {
13
13
  this.#pending.delete(id);
14
14
  reject(new Error(describeTimeout()));
15
15
  }, timeoutMs);
16
+ timer.unref();
16
17
  this.#pending.set(id, { resolve, reject, timer });
17
18
  });
18
19
  }
@@ -4,7 +4,8 @@ export interface SessionInfo {
4
4
  url: string;
5
5
  /** Stable build-stamped project identity; absent for v1.0 SDKs that don't send it. */
6
6
  projectId?: string;
7
- title: string;
7
+ /** Absent when the page has no title — never the empty string. Fall back to `url`. */
8
+ title?: string;
8
9
  adapters: string[];
9
10
  hasCapabilities: boolean;
10
11
  /** Present only when the page's SDK version differs from the daemon's — see version-skew.ts. */
@@ -22,6 +23,21 @@ export interface SessionInfo {
22
23
  pendingMarks?: number;
23
24
  /** present with pendingMarks — nudges the agent to drain them with reticle_review. */
24
25
  review_suggestion?: string;
26
+ /**
27
+ * Continuity of this attachment — `{ connectedSinceMs, outages, lastOutage? }`.
28
+ *
29
+ * A tab that dropped for four seconds and came back is indistinguishable from one that never left
30
+ * (#117), and that difference decides whether a verdict over the window can be trusted. Attached
31
+ * by `SessionManager.list()`, which is the layer that sees every connect and disconnect.
32
+ */
33
+ attachment?: {
34
+ connectedSinceMs: number;
35
+ outages: number;
36
+ lastOutage?: {
37
+ startedMs: number;
38
+ durationMs: number;
39
+ };
40
+ };
25
41
  }
26
42
  /** The read-only slice of a Session that the projection needs. Keeps this module class-free. */
27
43
  interface SessionView {
@@ -18,7 +18,11 @@ export function buildSessionInfo(session) {
18
18
  sessionId: session.id,
19
19
  url: session.url,
20
20
  ...(session.projectId === undefined ? {} : { projectId: session.projectId }),
21
- title: session.title,
21
+ // Omitted when blank, never `""`. An empty title cannot be told apart from "we sampled before
22
+ // the page set one", and two such tabs make the listing unusable. Moving the sample later does
23
+ // not fix it — a page may set its title after connect, or never — so the projection refuses the
24
+ // bad answer and the reader falls back to `url`, which is right here in the same record.
25
+ ...('' === session.title.trim() ? {} : { title: session.title }),
22
26
  adapters: session.adapters,
23
27
  hasCapabilities: session.hasCapabilities,
24
28
  // On every listing, not buried in a log — skew explains failures that read as app bugs.
@@ -1,4 +1,5 @@
1
1
  import { Session, type SessionInfo } from './session.js';
2
+ import { AttachmentHistory } from './attachment-history.js';
2
3
  /**
3
4
  * The agent's active project, used to scope auto-selection. `projectId` is the stable build-stamped
4
5
  * identity (authoritative when present); `url` is the app origin, a fallback hint for older SDKs that
@@ -18,6 +19,8 @@ export declare class SessionManager {
18
19
  remove(session: Session): boolean;
19
20
  get(sessionId: string): Session | undefined;
20
21
  list(): SessionInfo[];
22
+ /** Continuity for one session — "has this been attached the whole time I am reasoning about?" */
23
+ attachmentOf(sessionId: string): ReturnType<AttachmentHistory['of']>;
21
24
  /** Every connected session — used by the liveness reaper to sweep for idle/disconnected ones. */
22
25
  all(): Session[];
23
26
  count(): number;