@reticlehq/server 2.5.0 → 2.7.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 (182) hide show
  1. package/SKILL.md +226 -26
  2. package/dist/bridge/bridge.d.ts +18 -1
  3. package/dist/bridge/bridge.js +59 -6
  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/chromium-hint.d.ts +40 -0
  7. package/dist/cli/chromium-hint.js +75 -0
  8. package/dist/cli/cli-doctor.d.ts +0 -5
  9. package/dist/cli/cli-doctor.js +74 -14
  10. package/dist/cli/cli-launch.d.ts +1 -0
  11. package/dist/cli/cli-launch.js +4 -1
  12. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  13. package/dist/cli/cli-parse-feedback.js +56 -0
  14. package/dist/cli/cli-parse.d.ts +5 -9
  15. package/dist/cli/cli-parse.js +7 -23
  16. package/dist/cli/cli-port.js +20 -1
  17. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  18. package/dist/cli/doctor-daemon-line.js +44 -0
  19. package/dist/cli/port-holder.d.ts +43 -0
  20. package/dist/cli/port-holder.js +99 -0
  21. package/dist/cli.js +170 -19
  22. package/dist/daemon/daemon-resilience.d.ts +24 -0
  23. package/dist/daemon/daemon-resilience.js +105 -17
  24. package/dist/daemon/daemon.js +26 -1
  25. package/dist/daemon/heartbeat.d.ts +115 -0
  26. package/dist/daemon/heartbeat.js +141 -0
  27. package/dist/daemon/idle-shutdown.d.ts +13 -5
  28. package/dist/daemon/idle-shutdown.js +14 -6
  29. package/dist/daemon/loopback-alias.d.ts +32 -0
  30. package/dist/daemon/loopback-alias.js +80 -0
  31. package/dist/daemon/port-presence.d.ts +76 -0
  32. package/dist/daemon/port-presence.js +93 -0
  33. package/dist/daemon/state-dir.d.ts +7 -0
  34. package/dist/daemon/state-dir.js +59 -0
  35. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  36. package/dist/daemon/unclean-predecessor.js +27 -0
  37. package/dist/events/predicate-eval.d.ts +17 -3
  38. package/dist/events/predicate-eval.js +183 -96
  39. package/dist/events/predicate-parse.d.ts +23 -0
  40. package/dist/events/predicate-parse.js +68 -0
  41. package/dist/events/predicate.d.ts +5 -0
  42. package/dist/events/predicate.js +17 -3
  43. package/dist/events/ring-buffer.d.ts +8 -0
  44. package/dist/events/ring-buffer.js +35 -17
  45. package/dist/flows/flow-replay.d.ts +1 -1
  46. package/dist/flows/flow-replay.js +19 -2
  47. package/dist/flows/flow-step-runners.js +2 -3
  48. package/dist/flows/flow-tools.js +10 -14
  49. package/dist/flows/role-drift-reason.d.ts +20 -0
  50. package/dist/flows/role-drift-reason.js +39 -0
  51. package/dist/flows/suite-flakes.d.ts +33 -0
  52. package/dist/flows/suite-flakes.js +32 -0
  53. package/dist/honesty/blind-spots.d.ts +19 -0
  54. package/dist/honesty/blind-spots.js +33 -0
  55. package/dist/honesty/honesty.d.ts +11 -0
  56. package/dist/honesty/honesty.js +9 -1
  57. package/dist/honesty/verified.d.ts +10 -1
  58. package/dist/honesty/verified.js +42 -3
  59. package/dist/http-server.js +20 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +36 -3
  62. package/dist/init/agent-rules.d.ts +18 -0
  63. package/dist/init/agent-rules.js +28 -5
  64. package/dist/init/cra.d.ts +13 -0
  65. package/dist/init/cra.js +16 -0
  66. package/dist/init/detect.d.ts +14 -0
  67. package/dist/init/detect.js +23 -0
  68. package/dist/init/mcp-clients.d.ts +81 -0
  69. package/dist/init/mcp-clients.js +248 -0
  70. package/dist/init/node-io.js +8 -2
  71. package/dist/init/non-js-project.d.ts +27 -0
  72. package/dist/init/non-js-project.js +60 -0
  73. package/dist/init/plan-framework.d.ts +9 -0
  74. package/dist/init/plan-framework.js +55 -3
  75. package/dist/init/plan.d.ts +14 -0
  76. package/dist/init/plan.js +93 -12
  77. package/dist/init/run.d.ts +18 -1
  78. package/dist/init/run.js +169 -23
  79. package/dist/init/snippets.d.ts +20 -1
  80. package/dist/init/snippets.js +71 -6
  81. package/dist/init/windows-quote.d.ts +38 -0
  82. package/dist/init/windows-quote.js +70 -0
  83. package/dist/mcp/mcp-outage.d.ts +1 -0
  84. package/dist/mcp/mcp-outage.js +8 -1
  85. package/dist/mcp/mcp-proxy.d.ts +11 -1
  86. package/dist/mcp/mcp-proxy.js +39 -9
  87. package/dist/mcp/mcp.js +64 -2
  88. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  89. package/dist/mcp/proxy-lifecycle.js +21 -0
  90. package/dist/pool/browser-pool.d.ts +9 -0
  91. package/dist/pool/browser-pool.js +38 -9
  92. package/dist/pool/playwright-launcher.js +10 -2
  93. package/dist/session/attachment-history.d.ts +46 -0
  94. package/dist/session/attachment-history.js +65 -0
  95. package/dist/session/live-control-tools.js +39 -2
  96. package/dist/session/live-control.d.ts +7 -9
  97. package/dist/session/live-control.js +34 -1
  98. package/dist/session/no-session-diagnosis.d.ts +11 -4
  99. package/dist/session/no-session-diagnosis.js +95 -14
  100. package/dist/session/no-session-watch.d.ts +3 -1
  101. package/dist/session/no-session-watch.js +10 -2
  102. package/dist/session/pending-commands.js +1 -0
  103. package/dist/session/review-store.d.ts +19 -0
  104. package/dist/session/review-store.js +34 -5
  105. package/dist/session/session-info.d.ts +17 -1
  106. package/dist/session/session-info.js +5 -1
  107. package/dist/session/session-manager.d.ts +12 -0
  108. package/dist/session/session-manager.js +83 -3
  109. package/dist/session/session.d.ts +9 -0
  110. package/dist/session/session.js +20 -0
  111. package/dist/status-payload.d.ts +13 -1
  112. package/dist/status-payload.js +6 -1
  113. package/dist/telemetry/app-instrumented.d.ts +37 -0
  114. package/dist/telemetry/app-instrumented.js +54 -0
  115. package/dist/telemetry/crash-cause.d.ts +47 -0
  116. package/dist/telemetry/crash-cause.js +104 -0
  117. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  118. package/dist/telemetry/daemon-telemetry.js +32 -13
  119. package/dist/telemetry/error-class.d.ts +26 -0
  120. package/dist/telemetry/error-class.js +52 -0
  121. package/dist/telemetry/feedback-cli.d.ts +7 -2
  122. package/dist/telemetry/feedback-cli.js +49 -5
  123. package/dist/telemetry/feedback-context.d.ts +1 -6
  124. package/dist/telemetry/feedback-context.js +64 -4
  125. package/dist/telemetry/git-facts.d.ts +7 -0
  126. package/dist/telemetry/git-facts.js +4 -4
  127. package/dist/telemetry/project-profile.js +2 -1
  128. package/dist/telemetry/session-metrics.d.ts +45 -3
  129. package/dist/telemetry/session-metrics.js +247 -9
  130. package/dist/telemetry/telemetry.d.ts +21 -1
  131. package/dist/telemetry/telemetry.js +34 -9
  132. package/dist/telemetry/verification-of.js +26 -1
  133. package/dist/tools/act-tools.js +63 -14
  134. package/dist/tools/assert-verdict.d.ts +7 -1
  135. package/dist/tools/assert-verdict.js +22 -3
  136. package/dist/tools/browser-tools.js +8 -2
  137. package/dist/tools/contract-tools.d.ts +0 -5
  138. package/dist/tools/contract-tools.js +26 -0
  139. package/dist/tools/dynamic-tools.js +6 -1
  140. package/dist/tools/error-recovery.d.ts +14 -0
  141. package/dist/tools/error-recovery.js +89 -8
  142. package/dist/tools/feedback-invite.d.ts +48 -0
  143. package/dist/tools/feedback-invite.js +71 -0
  144. package/dist/tools/invoke-tool.d.ts +0 -6
  145. package/dist/tools/invoke-tool.js +76 -7
  146. package/dist/tools/navigate-arrival.d.ts +26 -0
  147. package/dist/tools/navigate-arrival.js +60 -0
  148. package/dist/tools/navigate-result.d.ts +15 -1
  149. package/dist/tools/navigate-result.js +17 -10
  150. package/dist/tools/observe-tools.js +22 -4
  151. package/dist/tools/real-input-attempt.js +10 -5
  152. package/dist/tools/tool-kit.d.ts +60 -5
  153. package/dist/tools/tool-kit.js +55 -24
  154. package/dist/tools/tool-surface.js +13 -0
  155. package/dist/tools/tools.js +22 -0
  156. package/dist/visual/capture-cleanup.d.ts +16 -0
  157. package/dist/visual/capture-cleanup.js +39 -0
  158. package/dist/visual/visual-tools.d.ts +18 -0
  159. package/dist/visual/visual-tools.js +24 -5
  160. package/docs/README.md +55 -0
  161. package/docs/agent-cheatsheet.md +15 -3
  162. package/docs/benchmarks.md +1 -1
  163. package/docs/fixtures-dispatch-receiver.yml +76 -0
  164. package/docs/fixtures.md +17 -0
  165. package/docs/gate-plan.md +98 -0
  166. package/docs/gates.md +117 -0
  167. package/docs/local-registry.md +1 -1
  168. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  169. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  170. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  171. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  172. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  173. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  174. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  175. package/docs/matrix/MATRIX.md +26 -0
  176. package/docs/matrix/README.md +59 -0
  177. package/docs/mint.json +1 -1
  178. package/docs/system-map.md +152 -0
  179. package/docs/telemetry-contract.md +15 -3
  180. package/docs/telemetry.md +16 -1
  181. package/docs/token-efficiency.md +2 -2
  182. package/package.json +2 -2
@@ -8,6 +8,7 @@
8
8
  import { BrowserLaunchKind } from '@reticlehq/core';
9
9
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
10
10
  import { classifyConnectFailure } from '../telemetry/connect-failure.js';
11
+ import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
11
12
  /**
12
13
  * How a leased tab navigates. Pure, and exported, because the decision in it is worth a test while
13
14
  * the rest of this file needs a real Chromium.
@@ -48,8 +49,15 @@ function wrapBrowser(browser) {
48
49
  onDisconnected: (handler) => browser.on('disconnected', handler),
49
50
  };
50
51
  }
51
- /** The fix for the most common first-run failure: Chromium isn't installed for Playwright. */
52
- const CHROMIUM_MISSING_HINT = 'Chromium is not installed for Playwright. Run: npx playwright install chromium';
52
+ /**
53
+ * The fix for the most common first-run failure: Chromium isn't installed for Playwright.
54
+ *
55
+ * Pinned to the playwright this process bundles. Unpinned, `npx` fetches the latest playwright and
56
+ * installs ITS browser revision — a build this launcher will never look at — so the user follows the
57
+ * advice, it succeeds, and the next launch fails identically. Shares one builder with `doctor` so the
58
+ * two cannot suggest different commands for the same missing browser.
59
+ */
60
+ const CHROMIUM_MISSING_HINT = `Chromium is not installed for Playwright. Run: ${chromiumInstallCommand(bundledPlaywrightVersion())}`;
53
61
  /** A Launcher that boots a real headless Chromium and adapts it to the pool's interface. */
54
62
  export function playwrightLauncher(opts = {}) {
55
63
  const headless = opts.headless ?? true;
@@ -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
+ }
@@ -93,12 +93,37 @@ export const LIVE_CONTROL_TOOLS = [
93
93
  description: 'Drain and return any messages the human queued from the panel since the last poll. Use to ' +
94
94
  'explicitly check for human guidance without acting.',
95
95
  inputSchema: { ...sessionIdShape },
96
- outputSchema: { messages: z.array(z.unknown()) },
96
+ outputSchema: {
97
+ messages: z.array(z.unknown()),
98
+ delivered: z
99
+ .array(z.unknown())
100
+ .optional()
101
+ .describe('Present ONLY when this poll found nothing new but the human HAS spoken this session: everything they said, including what was already handed to you inline on an earlier tool result. An empty `messages` never means silence.'),
102
+ },
97
103
  handler: (deps, args) => {
98
104
  const session = deps.sessions.resolve(asString(args['sessionId']));
105
+ const messages = session.drainInbox();
106
+ if (messages.length > 0)
107
+ return Promise.resolve({ messages });
108
+ // Nothing NEW is not the same as nothing said. The inbox has two consumers — this poll and the
109
+ // control envelope spliced onto every tool result — and delivery is destructive, so a message
110
+ // handed over inline moments ago left this call reporting an empty queue with a note that read
111
+ // as "the human has said nothing". Reported from the field, from both ends: the agent believed
112
+ // it, and the person who typed the message got silence with no sign it had been seen.
113
+ const history = session.inboxHistory();
114
+ if (history.length > 0) {
115
+ return Promise.resolve({
116
+ messages,
117
+ delivered: [...history],
118
+ note: `nothing NEW since your last poll — but the human has sent ` +
119
+ `${String(history.length)} message(s) this session, listed in \`delivered\`. They were ` +
120
+ `handed to you inline on an earlier tool result, which is why this queue is empty. An ` +
121
+ `empty \`messages\` never means the human has said nothing.`,
122
+ });
123
+ }
99
124
  // An empty inbox and a panel that is not wired both return `[]`. The first means the human has
100
125
  // said nothing; the second means an agent is waiting on a channel that does not exist.
101
- return Promise.resolve(noteEmptyRead({ messages: session.drainInbox() }, 'messages', {
126
+ return Promise.resolve(noteEmptyRead({ messages }, 'messages', {
102
127
  noun: 'messages from the human since the last poll',
103
128
  }));
104
129
  },
@@ -126,16 +151,26 @@ export const LIVE_CONTROL_TOOLS = [
126
151
  marks: z.array(z.unknown()),
127
152
  pendingCount: z.number(),
128
153
  resolved: z.boolean().optional(),
154
+ resolvedNote: z
155
+ .string()
156
+ .optional()
157
+ .describe('The note on the mark that was actually retired. CHECK IT against the mark you meant to resolve: a bare `resolved:true` cannot tell you it closed the right one, and ids are only meaningful while the session that issued them lives.'),
129
158
  },
130
159
  handler: (deps, args) => {
131
160
  const session = deps.sessions.resolve(asString(args['sessionId']));
132
161
  const resolveId = asString(args['resolve']);
133
162
  let resolved;
163
+ let resolvedNote;
134
164
  if (resolveId !== undefined) {
135
165
  // Grab the note BEFORE retiring it so we can close the loop visually for the human.
136
166
  const mark = session.allMarks().find((m) => m.id === resolveId);
137
167
  resolved = session.resolveMark(resolveId);
138
168
  if (resolved && mark !== undefined) {
169
+ // Echoed back to the CALLER too, not only to the panel. A bare `resolved:true` is equally
170
+ // consistent with "I retired the bug you fixed" and "I retired somebody else's", and an
171
+ // agent hit exactly that — it closed a mark the human had recorded minutes later while
172
+ // the two it had actually fixed vanished with no record.
173
+ resolvedNote = mark.note;
139
174
  // The human watching the panel sees their flagged bug get marked fixed (fire-and-forget).
140
175
  session.pushNarration(`✓ fixed: ${mark.note}`);
141
176
  }
@@ -148,6 +183,8 @@ export const LIVE_CONTROL_TOOLS = [
148
183
  };
149
184
  if (resolved !== undefined)
150
185
  out.resolved = resolved;
186
+ if (resolvedNote !== undefined)
187
+ out.resolvedNote = resolvedNote;
151
188
  return Promise.resolve(out);
152
189
  },
153
190
  },
@@ -4,15 +4,6 @@ export interface InboxMessage {
4
4
  text: string;
5
5
  t: number;
6
6
  }
7
- /**
8
- * The human-in-the-loop half of a session: its lifecycle state, and the one-way inbox a person uses
9
- * to talk to the agent driving their browser.
10
- *
11
- * Server-owned on purpose — a paused session has to stay paused even if the page reloads, so this
12
- * cannot live in the tab. Split out of Session because it shares nothing with the transport, the
13
- * ring buffer, or the journal: it is state a HUMAN changes, on a human's timescale, while everything
14
- * else in Session is driven by the event stream.
15
- */
16
7
  export declare class LiveControl {
17
8
  #private;
18
9
  state(): SessionState;
@@ -22,6 +13,13 @@ export declare class LiveControl {
22
13
  setState(next: SessionState): void;
23
14
  /** Queue a human note. Empty/whitespace-only text is ignored so a stray enter cannot spam the agent. */
24
15
  push(text: string, t: number): void;
16
+ /**
17
+ * Everything the human has said this session, including what has already been delivered.
18
+ *
19
+ * Read-only and non-destructive by design — this is the answer to "did the human say anything
20
+ * while I was away", which a destructive drain can never give twice.
21
+ */
22
+ history(): readonly InboxMessage[];
25
23
  /** Return the queued notes AND clear them — delivered once, so a hint never repeats. */
26
24
  drain(): InboxMessage[];
27
25
  /** Diagnostic read of the inbox depth (does not clear). */
@@ -8,9 +8,29 @@ import { HumanControlKind, SessionState } from '@reticlehq/core';
8
8
  * ring buffer, or the journal: it is state a HUMAN changes, on a human's timescale, while everything
9
9
  * else in Session is driven by the event stream.
10
10
  */
11
+ /**
12
+ * How many delivered messages a session remembers.
13
+ *
14
+ * Enough that an agent picking up a long session can see what was said; bounded because a session
15
+ * can run for hours and this is a convenience, not a record.
16
+ */
17
+ const HISTORY_CAP = 200;
11
18
  export class LiveControl {
12
19
  #state = SessionState.ACTIVE;
13
20
  #inbox = [];
21
+ /**
22
+ * Everything the human has said this session, delivered or not.
23
+ *
24
+ * The inbox has TWO consumers — the control envelope on every tool result, and the explicit
25
+ * message poll — and `drain` is destructive, so whichever ran first took the message and the other
26
+ * reported an empty queue. That produced a confident "no messages from the human since the last
27
+ * poll" about a message the human had definitely sent, with no way for the agent to tell the
28
+ * difference and no acknowledgement for the person who typed it.
29
+ *
30
+ * Delivered-once stays: a guidance hint that repeats every turn is noise. This only stops the
31
+ * delivery from being the same event as the forgetting.
32
+ */
33
+ #history = [];
14
34
  state() {
15
35
  return this.#state;
16
36
  }
@@ -29,7 +49,20 @@ export class LiveControl {
29
49
  const trimmed = text.trim();
30
50
  if (0 === trimmed.length)
31
51
  return;
32
- this.#inbox.push({ text: trimmed, t });
52
+ const message = { text: trimmed, t };
53
+ this.#inbox.push(message);
54
+ this.#history.push(message);
55
+ if (this.#history.length > HISTORY_CAP)
56
+ this.#history.splice(0, this.#history.length - HISTORY_CAP);
57
+ }
58
+ /**
59
+ * Everything the human has said this session, including what has already been delivered.
60
+ *
61
+ * Read-only and non-destructive by design — this is the answer to "did the human say anything
62
+ * while I was away", which a destructive drain can never give twice.
63
+ */
64
+ history() {
65
+ return this.#history;
33
66
  }
34
67
  /** Return the queued notes AND clear them — delivered once, so a hint never repeats. */
35
68
  drain() {
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Turning "no browser session connected" from a dead end into a next action.
3
3
  *
4
- * This is the most consequential sentence in the product. Measured over a day of telemetry: 74% of
5
- * sessions never call a Reticle tool, and of the ones that do, half make exactly ONE call — usually
6
- * `reticle_sessions` — and stop. Ten of those thirteen never touched a browser, and every recorded
7
- * session error is this one. The agent asks whether anything is connected, is told no, and leaves.
4
+ * This is the most consequential sentence in the product. Most sessions never call a Reticle tool at
5
+ * all, and of the ones that do, a large share make exactly ONE call — usually `reticle_sessions` —
6
+ * and stop. Almost none of those ever touched a browser, and this is the session error they hit. The
7
+ * agent asks whether anything is connected, is told no, and leaves.
8
8
  *
9
9
  * The old message asked the agent to check two things it cannot see from where it stands ("is your
10
10
  * app running with the SDK enabled?", "does it point at this port?"). The daemon can actually tell
@@ -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
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Turning "no browser session connected" from a dead end into a next action.
3
3
  *
4
- * This is the most consequential sentence in the product. Measured over a day of telemetry: 74% of
5
- * sessions never call a Reticle tool, and of the ones that do, half make exactly ONE call — usually
6
- * `reticle_sessions` — and stop. Ten of those thirteen never touched a browser, and every recorded
7
- * session error is this one. The agent asks whether anything is connected, is told no, and leaves.
4
+ * This is the most consequential sentence in the product. Most sessions never call a Reticle tool at
5
+ * all, and of the ones that do, a large share make exactly ONE call — usually `reticle_sessions` —
6
+ * and stop. Almost none of those ever touched a browser, and this is the session error they hit. The
7
+ * agent asks whether anything is connected, is told no, and leaves.
8
8
  *
9
9
  * The old message asked the agent to check two things it cannot see from where it stands ("is your
10
10
  * app running with the SDK enabled?", "does it point at this port?"). The daemon can actually tell
@@ -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
@@ -29,12 +30,12 @@ const RETRY = SELF_RECOVERING_MARKER;
29
30
  * The way out that needs no human at all.
30
31
  *
31
32
  * `reticle_lease` opens a browser Reticle drives itself, instead of waiting for somebody's tab to
32
- * dial in. Measured over a day of telemetry it is the single strongest predictor of a session that
33
- * works: the 5 sessions that used it had a MEDIAN of 30 tool calls and produced 46% of every bug
34
- * found, against a median of 1 call for the 20 active sessions that did not — and not one
35
- * single-call bounce used one. It is also advertised on no profile except `full`, so an agent only
36
- * ever finds it if it already knew it existed. Naming it HERE puts it in front of the agent at the
37
- * one moment it is the answer, and costs nothing on the turns when it is not.
33
+ * dial in. In the field it is the single strongest predictor of a session that works: sessions that
34
+ * use it drive an order of magnitude more tool calls than those that do not, they account for a
35
+ * disproportionate share of every bug found, and no single-call bounce has ever used one. It is also
36
+ * advertised on no profile except `full`, so an agent only ever finds it if it already knew it
37
+ * existed. Naming it HERE puts it in front of the agent at the one moment it is the answer, and
38
+ * costs nothing on the turns when it is not.
38
39
  *
39
40
  * Only offered when the app is known to carry the SDK: leasing an uninstrumented app just burns a
40
41
  * browser and comes back `ready:false`.
@@ -42,18 +43,98 @@ 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 one cause that produces a PERFECTLY healthy everything and still never connects.
55
+ *
56
+ * The SDK refuses to dial from a page that is not on localhost unless `allowNonLocalhost` is set.
57
+ * That is deliberate — Reticle must not be reachable from a page it does not trust — but the refusal
58
+ * happens page-side, so the daemon sees only silence, `doctor` sees only a healthy daemon, and every
59
+ * checklist item passes. Reported from the field by an agent on a hosts-file alias (the normal setup
60
+ * for white-label and multi-tenant apps): the whole setup was lost to a flag named in no checklist,
61
+ * and `init`'s own fallback snippet guards on `hostname === 'localhost'`, so on such a host the
62
+ * connect never runs and there is not even a console line to find.
63
+ *
64
+ * Only offered on the wired-and-listening branch: that is where a correctly installed app that
65
+ * cannot connect actually lands.
66
+ */
67
+ const NON_LOCALHOST_GATE = 'One more cause that leaves every other check healthy: if the app is served on anything other ' +
68
+ 'than localhost (a hosts-file alias, a LAN IP, a tunnel), the SDK refuses to connect unless it ' +
69
+ 'is given `allowNonLocalhost: true` — the refusal is page-side, so nothing here can see it. ' +
70
+ "Check the browser console for that message and pass the flag in the app's reticle.connect().";
71
+ const SCANNED_PORTS = [...DEV_SERVER_PORTS].join(', ');
45
72
  export function diagnoseNoSession(facts) {
46
73
  const { everConnected, initialized, listening, port } = facts;
47
74
  const ports = listening.join(', ');
48
75
  if (everConnected) {
76
+ // A reaped lease first, because it is the one cause we have POSITIVE evidence for. Reported
77
+ // from the field (#157): an aged-out lease produced "the tab was closed … ask the human to
78
+ // reopen the app", which is wrong on every clause — there is no human tab, and the recovery it
79
+ // names is unavailable to the caller while the one that works goes unmentioned. The reporter
80
+ // went looking for a port mismatch. Hedged rather than swapped: a human tab may ALSO have
81
+ // closed, and this does not know which session went.
82
+ if (true === facts.leaseExpired) {
83
+ return ('no browser session connected — but one WAS connected to this daemon earlier, so the wiring ' +
84
+ 'is correct. This daemon has expired at least one pooled lease, so the likeliest cause is ' +
85
+ 'that a lease you were using aged out; a lease is a headless context, not a human tab, and ' +
86
+ 'it takes its cookies with it (so an authenticated app needs signing in again). Re-acquire ' +
87
+ 'with reticle_lease {action:"acquire", url} and carry on. If you were driving a human tab ' +
88
+ `instead, it went away — reopen it or run \`reticle open\`. ${RETRY}`);
89
+ }
49
90
  return ('no browser session connected — but one WAS connected to this daemon earlier, so the wiring ' +
50
91
  'is correct. The tab was closed, navigated away, or hard-reloaded. Ask the human to reopen ' +
51
92
  `the app (or run \`reticle open\`), or reload the tab. ${SELF_SERVE} ${RETRY}`);
52
93
  }
53
94
  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}`);
95
+ // Lead with the stronger EVIDENCE, and do not overstate what it is.
96
+ //
97
+ // `initialized` is one thing only: whether a `.reticle.json` sits in the directory this daemon
98
+ // is running in. It is NOT "this project has never been through `reticle init`", and the two
99
+ // come apart constantly — a monorepo whose daemon runs at the root while the app lives in
100
+ // `apps/web`, or any app wired by the babel/vite plugin rather than by `init`. An earlier
101
+ // version of this branch asserted the strong claim and led with it, on the reasoning that it was
102
+ // a certainty while the port scan was a guess. It is not a certainty. Caught driving Reticle's
103
+ // OWN repo, where the message told an agent the project had never been through `init` about a
104
+ // fixture that is instrumented and working — "the one sentence I was most likely to act on was
105
+ // wrong", which is the exact failure this whole file exists to prevent.
106
+ //
107
+ // So: report what was actually checked, name the directory, and name the case where the absence
108
+ // is expected rather than diagnostic.
109
+ if (!initialized) {
110
+ return ('no browser session connected. Two things to weigh, and neither of them is proof. ' +
111
+ `(1) Nothing is listening on the ports Reticle scans (${SCANNED_PORTS}), so the dev server ` +
112
+ 'may not be running — ask the human to start it (`npm run dev`). That scan is narrow ' +
113
+ 'though: a server on any other port is invisible to it, so if the app IS running, ask for ' +
114
+ 'its URL rather than assuming it is down. ' +
115
+ '(2) There is no `.reticle.json` in the directory this daemon is running in. That is the ' +
116
+ "file `reticle init` writes, so the app may carry no Reticle SDK — but check the app's " +
117
+ 'OWN directory before re-running `init`: in a monorepo the daemon often runs at the root ' +
118
+ 'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
119
+ `carries the SDK without that file at all. ${RETRY}`);
120
+ }
121
+ return ('no browser session connected, and nothing is listening on the ports Reticle scans ' +
122
+ `(${SCANNED_PORTS}). The likeliest cause by far is that the dev server is not running: ask ` +
123
+ 'the human to start it (`npm run dev`), then open the app in a browser. ' +
124
+ // The caveat is here rather than omitted because the scan is NARROW, and the old sentence
125
+ // spent its confidence as though an empty result were proof of absence. Reported twice: a
126
+ // scripted drive of 2.5.0 asserted the app was not running while it served 200 on :7699, and
127
+ // an agent was told nothing was listening while a dev server answered on :5000 under a custom
128
+ // hostname. The common defaults have since been added to the scanned set, which narrows the
129
+ // gap and cannot close it — anything passed to `--port` is still invisible.
130
+ 'That scan is narrow, so it is not proof: a server on any other port is invisible to it. If ' +
131
+ // Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
132
+ // if nothing is listening there is nothing at any URL to open. Asking for the real one is the
133
+ // only move that can recover the :7699 case.
134
+ 'the app IS running, ask the human for its URL rather than assuming it is down. ' +
135
+ // Reachable only for a project that HAS been through `init` — the uninstrumented case is
136
+ // answered above, leading with that certainty instead of behind this scan's guess.
137
+ `${RETRY}`);
57
138
  }
58
139
  if (!initialized) {
59
140
  return (`no browser session connected, but something IS listening on port ${ports} — so a server is ` +
@@ -66,5 +147,5 @@ export function diagnoseNoSession(facts) {
66
147
  `wired for Reticle — so the app is either serving a build made before the wiring landed, or ` +
67
148
  `dialling a different daemon than this one (this daemon is on ${String(port)}). Ask the human ` +
68
149
  'to restart the dev server and hard-reload the page; if it still does not appear, check that ' +
69
- `the app's reticle port matches ${String(port)}. ${SELF_SERVE} ${RETRY}`);
150
+ `the app's reticle port matches ${String(port)}. ${NON_LOCALHOST_GATE} ${SELF_SERVE} ${RETRY}`);
70
151
  }
@@ -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
  }
@@ -21,6 +21,15 @@ export interface ReviewMark {
21
21
  at: number;
22
22
  status: MarkStatus;
23
23
  }
24
+ /** Tests only — assertions on `m1` must not depend on which test ran first. */
25
+ export declare function resetMarkIdsForTest(): void;
26
+ /** What `resolve` actually did, including WHICH mark — so a caller can verify it hit the right one. */
27
+ export interface ResolveOutcome {
28
+ resolved: boolean;
29
+ id: string;
30
+ /** The retired mark's note. Absent when nothing was resolved. */
31
+ note?: string;
32
+ }
24
33
  export declare class ReviewStore {
25
34
  #private;
26
35
  /** Store a new mark (status pending) stamped with the caller-supplied session-relative time. */
@@ -36,4 +45,14 @@ export declare class ReviewStore {
36
45
  * false for an unknown id or an already-resolved mark (so resolve is idempotent).
37
46
  */
38
47
  resolve(id: string): boolean;
48
+ /**
49
+ * Resolve, and say WHICH mark was retired.
50
+ *
51
+ * The bare boolean is what made the misattribution silent: `{"resolved":true}` is equally
52
+ * consistent with "I retired the bug you fixed" and "I retired somebody else's". Echoing the note
53
+ * lets a caller check the answer against what it believed it was resolving, which is exactly what
54
+ * the agent that hit this asked for — it had been re-reading the whole list after every resolve
55
+ * and hand-diffing the notes to get the same assurance.
56
+ */
57
+ resolveDetail(id: string): ResolveOutcome;
39
58
  }
@@ -11,14 +11,31 @@ import { MarkStatus } from '@reticlehq/core';
11
11
  */
12
12
  /** Prefix on review-mark ids (m1, m2, …) — distinguishes them from command ids. */
13
13
  const MARK_ID_PREFIX = 'm';
14
+ /**
15
+ * Shared by every store in the process, so an id is never reissued.
16
+ *
17
+ * The sequence used to live on the instance, which looked right and was not: a store is created per
18
+ * Session, and a Session is recreated on every page reload and socket reattach. Each new store began
19
+ * again at `m1`, so an id an agent was still holding silently came to denote a DIFFERENT mark — and
20
+ * `resolve` retired that one instead, reporting success. Reported from the field by an agent that
21
+ * closed a bug nobody had fixed while the two it had fixed vanished unrecorded.
22
+ *
23
+ * Process-wide is deliberately not "forever": a daemon restart resets it, but a restart also
24
+ * destroys every session, so any id from before it resolves to nothing and `resolve` correctly
25
+ * returns false. The identity echo below covers what remains.
26
+ */
27
+ let nextMarkId = 0;
28
+ /** Tests only — assertions on `m1` must not depend on which test ran first. */
29
+ export function resetMarkIdsForTest() {
30
+ nextMarkId = 0;
31
+ }
14
32
  export class ReviewStore {
15
33
  #marks = [];
16
- #seq = 0;
17
34
  /** Store a new mark (status pending) stamped with the caller-supplied session-relative time. */
18
35
  add(data, at) {
19
- this.#seq += 1;
36
+ nextMarkId += 1;
20
37
  const mark = {
21
- id: `${MARK_ID_PREFIX}${String(this.#seq)}`,
38
+ id: `${MARK_ID_PREFIX}${String(nextMarkId)}`,
22
39
  note: data.note,
23
40
  anchor: data.anchor,
24
41
  strategy: data.strategy,
@@ -51,10 +68,22 @@ export class ReviewStore {
51
68
  * false for an unknown id or an already-resolved mark (so resolve is idempotent).
52
69
  */
53
70
  resolve(id) {
71
+ return this.resolveDetail(id).resolved;
72
+ }
73
+ /**
74
+ * Resolve, and say WHICH mark was retired.
75
+ *
76
+ * The bare boolean is what made the misattribution silent: `{"resolved":true}` is equally
77
+ * consistent with "I retired the bug you fixed" and "I retired somebody else's". Echoing the note
78
+ * lets a caller check the answer against what it believed it was resolving, which is exactly what
79
+ * the agent that hit this asked for — it had been re-reading the whole list after every resolve
80
+ * and hand-diffing the notes to get the same assurance.
81
+ */
82
+ resolveDetail(id) {
54
83
  const mark = this.#marks.find((m) => m.id === id);
55
84
  if (mark === undefined || mark.status === MarkStatus.RESOLVED)
56
- return false;
85
+ return { resolved: false, id };
57
86
  mark.status = MarkStatus.RESOLVED;
58
- return true;
87
+ return { resolved: true, id, note: mark.note };
59
88
  }
60
89
  }