@reticlehq/server 3.1.0 → 3.2.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 (228) hide show
  1. package/SKILL.md +17 -18
  2. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  3. package/dist/command/cli/cli-doctor.js +13 -1
  4. package/dist/command/cli/cli-flow-commands.d.ts +2 -1
  5. package/dist/command/cli/cli-kill.js +1 -1
  6. package/dist/command/cli/cli-parse.d.ts +2 -1
  7. package/dist/command/cli/cli-parse.js +5 -2
  8. package/dist/command/cli/cli-verify.js +2 -2
  9. package/dist/command/cli/cloud-cli.js +7 -6
  10. package/dist/command/cli/cloud-kit.js +2 -6
  11. package/dist/command/cli/config/config-discovery.d.ts +12 -0
  12. package/dist/command/cli/config/config-discovery.js +19 -0
  13. package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
  14. package/dist/command/cli/doctor/doctor-rows.js +12 -0
  15. package/dist/command/cli/drive/drive-command.js +1 -1
  16. package/dist/command/cli/launch/cli-launch.d.ts +10 -2
  17. package/dist/command/cli/launch/cli-launch.js +32 -39
  18. package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
  19. package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
  20. package/dist/command/cli/mcp-command.js +5 -3
  21. package/dist/command/cli/ports/resolve/cli-port.d.ts +2 -1
  22. package/dist/command/cli/ports/resolve/cli-port.js +5 -1
  23. package/dist/command/cli/status/status-lines.d.ts +28 -0
  24. package/dist/command/cli/status/status-lines.js +117 -0
  25. package/dist/command/cli.d.ts +1 -1
  26. package/dist/command/cli.js +24 -177
  27. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  28. package/dist/command/daemon/binding/daemon-status-probe.js +48 -0
  29. package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
  30. package/dist/command/daemon/binding/loopback-reach.js +63 -0
  31. package/dist/command/daemon/daemon-resilience.d.ts +13 -1
  32. package/dist/command/daemon/daemon-resilience.js +12 -1
  33. package/dist/command/daemon/daemon.d.ts +2 -11
  34. package/dist/command/daemon/daemon.js +8 -11
  35. package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
  36. package/dist/command/daemon/lifetime/heartbeat.js +10 -0
  37. package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
  38. package/dist/command/daemon/lifetime/log-cap.js +115 -0
  39. package/dist/command/setup/agent-notice-memory.d.ts +16 -0
  40. package/dist/command/setup/agent-notice-memory.js +65 -0
  41. package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
  42. package/dist/command/setup/bringup/dev-server-wait.js +16 -0
  43. package/dist/command/setup/bringup/ensure-daemon.js +1 -1
  44. package/dist/command/setup/init/init-runtime.js +13 -1
  45. package/dist/command/setup/node-effects.js +1 -1
  46. package/dist/command/setup/probe/page-probe.js +9 -2
  47. package/dist/command/setup/run-setup.d.ts +25 -7
  48. package/dist/command/setup/run-setup.js +51 -9
  49. package/dist/command/setup/setup-command.js +31 -3
  50. package/dist/command/setup/terminal/confirm.js +2 -1
  51. package/dist/command/update/updater.d.ts +27 -0
  52. package/dist/command/update/updater.js +35 -2
  53. package/dist/features/crawl/crawl-tools.js +3 -1
  54. package/dist/features/crawl/crawl.js +2 -1
  55. package/dist/features/harness/drive-report.d.ts +73 -0
  56. package/dist/features/harness/drive-report.js +196 -0
  57. package/dist/features/harness/driver.js +29 -8
  58. package/dist/features/harness/drivers.d.ts +19 -0
  59. package/dist/features/harness/drivers.js +19 -0
  60. package/dist/features/harness/fill-values.d.ts +67 -0
  61. package/dist/features/harness/fill-values.js +153 -0
  62. package/dist/features/harness/jev-driver.d.ts +188 -0
  63. package/dist/features/harness/jev-driver.js +664 -0
  64. package/dist/features/harness/openai-driver.d.ts +46 -0
  65. package/dist/features/harness/openai-driver.js +197 -0
  66. package/dist/features/harness/platform-config.d.ts +52 -0
  67. package/dist/features/harness/platform-config.js +89 -0
  68. package/dist/features/visual/visual-tools.js +26 -2
  69. package/dist/index.js +60 -55
  70. package/dist/judgement/domain/domain-tools.js +4 -2
  71. package/dist/judgement/runs/verification-sync.d.ts +2 -2
  72. package/dist/judgement/runs/verification-sync.js +9 -2
  73. package/dist/language/flows/affected-tools.js +6 -3
  74. package/dist/language/flows/flow-replay-run.d.ts +25 -2
  75. package/dist/language/flows/flow-replay-run.js +73 -27
  76. package/dist/language/flows/flow-replay.d.ts +17 -0
  77. package/dist/language/flows/flow-replay.js +148 -100
  78. package/dist/language/flows/flow-result.d.ts +18 -1
  79. package/dist/language/flows/flow-result.js +18 -2
  80. package/dist/language/flows/flow-store-for-session.d.ts +2 -1
  81. package/dist/language/flows/flow-tools.js +8 -4
  82. package/dist/language/flows/flows.d.ts +8 -7
  83. package/dist/language/flows/flows.js +7 -3
  84. package/dist/language/flows/narrow-write.d.ts +4 -4
  85. package/dist/language/flows/replay.js +1 -1
  86. package/dist/language/flows/suite-selection.d.ts +15 -1
  87. package/dist/language/flows/suite-selection.js +1 -1
  88. package/dist/language/flows/verify-change-tools.js +7 -4
  89. package/dist/memory/cloud/account-state.d.ts +1 -1
  90. package/dist/memory/cloud/account-state.js +5 -4
  91. package/dist/memory/cloud/cloud-config.d.ts +9 -0
  92. package/dist/memory/cloud/cloud-config.js +9 -0
  93. package/dist/memory/cloud/cloud-sync.d.ts +7 -2
  94. package/dist/memory/cloud/cloud-sync.js +10 -5
  95. package/dist/memory/cloud/harness-config.d.ts +10 -0
  96. package/dist/memory/cloud/harness-config.js +58 -0
  97. package/dist/memory/cloud/harness-offer.d.ts +44 -0
  98. package/dist/memory/cloud/harness-offer.js +146 -0
  99. package/dist/memory/cloud/harness-switch.d.ts +11 -0
  100. package/dist/memory/cloud/harness-switch.js +53 -0
  101. package/dist/memory/cloud/sync-daemon.d.ts +22 -2
  102. package/dist/memory/cloud/sync-daemon.js +24 -0
  103. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  104. package/dist/memory/impact/impact-recorder.js +2 -1
  105. package/dist/memory/impact/impact-store.d.ts +5 -0
  106. package/dist/memory/impact/impact-store.js +19 -0
  107. package/dist/memory/intent/intent-shard-store.js +2 -2
  108. package/dist/memory/intent/intent-shard.d.ts +12 -12
  109. package/dist/memory/journal/attach-journal.d.ts +11 -0
  110. package/dist/memory/journal/attach-journal.js +1 -1
  111. package/dist/memory/journal/journal-query.d.ts +35 -1
  112. package/dist/memory/journal/journal-query.js +46 -0
  113. package/dist/memory/journal/journal-recorder.d.ts +50 -2
  114. package/dist/memory/journal/journal-recorder.js +45 -7
  115. package/dist/memory/journal/on-disk/retention.d.ts +87 -2
  116. package/dist/memory/journal/on-disk/retention.js +177 -8
  117. package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +9 -0
  118. package/dist/memory/journal/on-disk/workspace-gitignore.js +9 -2
  119. package/dist/memory/journal/self-observation.d.ts +3 -0
  120. package/dist/memory/journal/self-observation.js +67 -0
  121. package/dist/memory/journal/session-end.d.ts +13 -3
  122. package/dist/memory/journal/session-end.js +24 -4
  123. package/dist/memory/journal/session-journal.d.ts +115 -5
  124. package/dist/memory/journal/session-journal.js +479 -32
  125. package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
  126. package/dist/memory/project/artifact-root-resolver.js +89 -0
  127. package/dist/memory/project/artifact-root.d.ts +39 -2
  128. package/dist/memory/project/artifact-root.js +58 -1
  129. package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
  130. package/dist/memory/project/dir/fill-value-store.js +88 -0
  131. package/dist/memory/project/dir/reticle-dir.d.ts +14 -4
  132. package/dist/memory/project/dir/reticle-dir.js +11 -0
  133. package/dist/memory/project/fs/fs-port.d.ts +22 -1
  134. package/dist/memory/project/fs/fs-port.js +33 -5
  135. package/dist/memory/project/learned-routes.d.ts +16 -2
  136. package/dist/memory/project/learned-routes.js +26 -10
  137. package/dist/memory/project/project-for-root.d.ts +30 -0
  138. package/dist/memory/project/project-for-root.js +43 -0
  139. package/dist/memory/project/project-tools.js +10 -5
  140. package/dist/memory/project/session-root.d.ts +35 -6
  141. package/dist/memory/project/session-root.js +70 -8
  142. package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
  143. package/dist/portal/bridge/auth-failure-reason.js +15 -1
  144. package/dist/portal/bridge/bridge.d.ts +6 -1
  145. package/dist/portal/bridge/bridge.js +36 -2
  146. package/dist/portal/session/facts/session-replaced.d.ts +19 -0
  147. package/dist/portal/session/facts/session-replaced.js +22 -0
  148. package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
  149. package/dist/portal/session/no-session-diagnosis.js +67 -60
  150. package/dist/portal/session/no-session-next-action.js +3 -2
  151. package/dist/portal/session/no-session-watch.js +14 -10
  152. package/dist/portal/session/rebind.d.ts +18 -0
  153. package/dist/portal/session/rebind.js +38 -0
  154. package/dist/portal/session/session-manager.d.ts +4 -0
  155. package/dist/portal/session/session-manager.js +19 -1
  156. package/dist/portal/session/session.d.ts +21 -10
  157. package/dist/portal/session/session.js +29 -42
  158. package/dist/status-payload.d.ts +12 -1
  159. package/dist/status-payload.js +4 -1
  160. package/dist/surface/http-server.js +3 -5
  161. package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
  162. package/dist/surface/mcp/faults/mcp-outage.js +20 -4
  163. package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
  164. package/dist/surface/mcp/mcp-proxy.js +20 -9
  165. package/dist/surface/mcp/mcp.d.ts +10 -1
  166. package/dist/surface/mcp/mcp.js +59 -29
  167. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
  168. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
  169. package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
  170. package/dist/surface/mcp/proxy/proxy-log.js +7 -22
  171. package/dist/surface/tools/act/act-preflight.js +6 -1
  172. package/dist/surface/tools/act/sequence-grade.js +13 -0
  173. package/dist/surface/tools/act-capsule.js +27 -1
  174. package/dist/surface/tools/assert/assert-verdict.js +44 -17
  175. package/dist/surface/tools/coverage-tools.js +5 -2
  176. package/dist/surface/tools/dynamic-tools.js +37 -46
  177. package/dist/surface/tools/error-recovery.d.ts +6 -0
  178. package/dist/surface/tools/error-recovery.js +15 -0
  179. package/dist/surface/tools/explore-tools.js +80 -7
  180. package/dist/surface/tools/feedback-tools.js +49 -2
  181. package/dist/surface/tools/first-sentence.d.ts +24 -0
  182. package/dist/surface/tools/first-sentence.js +0 -0
  183. package/dist/surface/tools/harness-explore.d.ts +125 -1
  184. package/dist/surface/tools/harness-explore.js +401 -15
  185. package/dist/surface/tools/harness-plan.d.ts +76 -0
  186. package/dist/surface/tools/harness-plan.js +156 -0
  187. package/dist/surface/tools/harness-toolset.js +12 -1
  188. package/dist/surface/tools/invoke-tool.js +65 -5
  189. package/dist/surface/tools/live-call-text.d.ts +6 -0
  190. package/dist/surface/tools/live-call-text.js +36 -2
  191. package/dist/surface/tools/observe-tools.js +55 -9
  192. package/dist/surface/tools/read/query-windowed.d.ts +1 -1
  193. package/dist/surface/tools/read/query-windowed.js +1 -1
  194. package/dist/surface/tools/read-tools.js +1 -2
  195. package/dist/surface/tools/tool-kit.d.ts +18 -3
  196. package/dist/surface/tools/tools-helpers.d.ts +8 -7
  197. package/dist/surface/tools/tools-helpers.js +11 -13
  198. package/dist/surface/tools/tools.js +6 -1
  199. package/dist/telemetry/bug-found.js +32 -2
  200. package/dist/telemetry/feedback-context.js +3 -3
  201. package/dist/telemetry/harness-drive.d.ts +6 -0
  202. package/dist/telemetry/harness-drive.js +39 -0
  203. package/dist/telemetry/mcp-connection.d.ts +14 -1
  204. package/dist/telemetry/mcp-connection.js +17 -2
  205. package/dist/telemetry/onboarding-firsts.d.ts +30 -1
  206. package/dist/telemetry/onboarding-firsts.js +54 -12
  207. package/dist/telemetry/verification-of.js +31 -3
  208. package/docs/agent-cheatsheet.md +7 -3
  209. package/docs/autodrive.md +42 -2
  210. package/docs/benchmarks.md +1 -1
  211. package/docs/capabilities.mdx +1 -1
  212. package/docs/cli/cloud.mdx +3 -3
  213. package/docs/cli/doctor.mdx +1 -0
  214. package/docs/cli/status.mdx +35 -20
  215. package/docs/cli.mdx +8 -18
  216. package/docs/desktop.mdx +1 -1
  217. package/docs/faq.mdx +1 -1
  218. package/docs/install-manual.mdx +1 -1
  219. package/docs/packages/tauri.mdx +1 -1
  220. package/docs/packages.mdx +1 -1
  221. package/docs/predicates.mdx +16 -0
  222. package/docs/system-map.md +6 -4
  223. package/docs/telemetry.md +2 -1
  224. package/docs/token-efficiency.md +2 -2
  225. package/docs/tools/overview.mdx +1 -1
  226. package/docs/troubleshooting.mdx +8 -2
  227. package/docs/usage.md +1 -1
  228. package/package.json +6 -6
@@ -2,7 +2,9 @@ import { CONTRACT_FINGERPRINT } from '@reticlehq/core';
2
2
  import { SERVER_VERSION } from './command/version/identity/server-version.js';
3
3
  export function statusPayload(sessionCount, sessions,
4
4
  /** The no-session diagnosis, injected so this stays pure and the port probe stays off this path. */
5
- why, verifyPort) {
5
+ why, verifyPort,
6
+ /** The short rendering of the same diagnosis. Absent on a daemon that has no provider wired. */
7
+ whyLead) {
6
8
  return {
7
9
  running: true,
8
10
  version: SERVER_VERSION,
@@ -12,6 +14,7 @@ why, verifyPort) {
12
14
  // Only when there is nothing to explain away: a diagnosis printed beside a live session would
13
15
  // contradict it.
14
16
  ...(0 === sessionCount && why !== undefined ? { why } : {}),
17
+ ...(0 === sessionCount && whyLead !== undefined ? { whyLead } : {}),
15
18
  ...(verifyPort === undefined ? {} : { verifyPort }),
16
19
  };
17
20
  }
@@ -4,7 +4,6 @@ import { MCP_SSE_PATH, MCP_MESSAGE_PATH, MCP_SHUTDOWN_EVENT, STATUS_PATH, DRIVE_
4
4
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
5
5
  import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM, } from '../command/version/peer-announce.js';
6
6
  import { log } from '../log.js';
7
- import { reportMcpConnected } from '../telemetry/mcp-connection.js';
8
7
  import { isLoopbackHost, isLocalWebOrigin, isLoopbackPeer, requestToken, tokensMatch, } from '../portal/bridge/token-auth.js';
9
8
  import { LOOPBACK_IDLE_MS } from './loopback-agent.js';
10
9
  /**
@@ -177,10 +176,9 @@ export function createSharedServer(options = {}) {
177
176
  .connect(transport)
178
177
  .then(() => {
179
178
  log('mcp_client_connected', { sessionId: sid });
180
- // The one signal that separates "Reticle is running" from "somebody is USING it": a daemon
181
- // can sit up for days with nothing attached. It also exposes reconnect churn, which is
182
- // indistinguishable from healthy usage in every other metric.
183
- reportMcpConnected();
179
+ // The telemetry for this moved to `oninitialized`, where the client has said its name.
180
+ // An SSE stream that opens here and never handshakes is a probe, not a client, and
181
+ // reporting it from this point produced a row that could never name who attached.
184
182
  })
185
183
  .catch((err) => {
186
184
  const message = err instanceof Error ? err.message : String(err);
@@ -6,7 +6,8 @@
6
6
  * often does a real user's MCP server go down, and does it come back — had no answer outside the
7
7
  * machine it happened on.
8
8
  *
9
- * Capped at two events per proxy process, and the cap is the design rather than a limitation. One
9
+ * Capped at one benign and one fault event PER STAGE per proxy process, and the cap is the design
10
+ * rather than a limitation. One
10
11
  * measured afternoon produced 547 proxy reconnects; an event per reconnect would bill for the
11
12
  * pathology instead of measuring it, which is the same mistake the per-call `tool` event made before
12
13
  * it was removed. What a dashboard needs is the SHARE OF SESSIONS that lose MCP at all (the first
@@ -6,7 +6,8 @@
6
6
  * often does a real user's MCP server go down, and does it come back — had no answer outside the
7
7
  * machine it happened on.
8
8
  *
9
- * Capped at two events per proxy process, and the cap is the design rather than a limitation. One
9
+ * Capped at one benign and one fault event PER STAGE per proxy process, and the cap is the design
10
+ * rather than a limitation. One
10
11
  * measured afternoon produced 547 proxy reconnects; an event per reconnect would bill for the
11
12
  * pathology instead of measuring it, which is the same mistake the per-call `tool` event made before
12
13
  * it was removed. What a dashboard needs is the SHARE OF SESSIONS that lose MCP at all (the first
@@ -28,6 +29,19 @@ const KNOWN_REASONS = new Set(Object.values(OutageReason));
28
29
  function outageReason(raw) {
29
30
  return KNOWN_REASONS.has(raw) ? raw : OutageReason.OTHER;
30
31
  }
32
+ /**
33
+ * The daemon retiring on schedule is not a fault, and it is by far the commonest drop.
34
+ *
35
+ * Kept as the CLASS rather than the reason itself, because the cap is keyed on it: one benign drop
36
+ * and one fault per stage, not one event per reason. Keying on the reason would put the volume the
37
+ * cap exists to prevent straight back — a flapping proxy has several reasons available to it and
38
+ * would bill for each of them — while keying on the stage alone is what let a scheduled shutdown at
39
+ * minute one permanently suppress a real outage at minute forty, biasing the metric toward exactly
40
+ * the rows `daemon_shutdown` was introduced to exclude.
41
+ */
42
+ function isBenign(reason) {
43
+ return OutageReason.DAEMON_SHUTDOWN === reason;
44
+ }
31
45
  const reported = new Set();
32
46
  /** Reset between tests; a real process reports each stage at most once. */
33
47
  export function resetOutageReporting() {
@@ -39,13 +53,15 @@ export function resetOutageReporting() {
39
53
  * fails — the reconnect it is describing.
40
54
  */
41
55
  export function reportMcpOutage(stage, facts) {
42
- if (reported.has(stage))
56
+ const reason = outageReason(facts.reason);
57
+ const slot = `${stage}:${isBenign(reason) ? 'benign' : 'fault'}`;
58
+ if (reported.has(slot))
43
59
  return;
44
- reported.add(stage);
60
+ reported.add(slot);
45
61
  void getTelemetry().emit(TelemetryEventKind.MCP_CONNECTION_LOST, {
46
62
  outage: {
47
63
  stage,
48
- reason: outageReason(facts.reason),
64
+ reason,
49
65
  attempts: facts.attempts,
50
66
  // Always sent, including zero: zero is the finding — a drop no agent could feel. See
51
67
  // McpOutageSchema.pendingLost.
@@ -107,4 +107,13 @@ export declare function startMcpProxy(port: number,
107
107
  */
108
108
  ensureDaemon?: () => Promise<void>,
109
109
  /** Observe the real shutdown boundary in transport tests without terminating their worker. */
110
- exitProcess?: (code: number) => void): Promise<never>;
110
+ exitProcess?: (code: number) => void,
111
+ /**
112
+ * Tell the resilience layer this session proved itself usable.
113
+ *
114
+ * The same `endpoint` frame that earns a fresh retry budget below also clears the disconnect-storm
115
+ * counter, and for the same reason: response headers are produced by a daemon that accepts SSE and
116
+ * drops it, and an `endpoint` frame is not. Optional, because a transport test drives this
117
+ * function with no resilience installed.
118
+ */
119
+ onUsableSession?: () => void): Promise<never>;
@@ -22,7 +22,7 @@ import { flushProxySessionMetrics } from '../../telemetry/proxy-telemetry.js';
22
22
  * the whole defect this import closes was the proxy answering a DIFFERENT question from every other
23
23
  * surface, and a second copy of the probe is how that comes back.
24
24
  */
25
- import { fetchStatus as fetchDaemonStatus } from '../../command/cli/launch/cli-launch.js';
25
+ import { fetchStatus as fetchDaemonStatus } from '../../command/daemon/binding/daemon-status-probe.js';
26
26
  /**
27
27
  * Re-exported so every existing caller (and every spec) keeps importing the proxy's log from the
28
28
  * proxy. Where the code lives is a file-size decision; where it is imported from is an API.
@@ -304,7 +304,16 @@ export function startMcpProxy(port,
304
304
  */
305
305
  ensureDaemon,
306
306
  /** Observe the real shutdown boundary in transport tests without terminating their worker. */
307
- exitProcess = (code) => process.exit(code)) {
307
+ exitProcess = (code) => process.exit(code),
308
+ /**
309
+ * Tell the resilience layer this session proved itself usable.
310
+ *
311
+ * The same `endpoint` frame that earns a fresh retry budget below also clears the disconnect-storm
312
+ * counter, and for the same reason: response headers are produced by a daemon that accepts SSE and
313
+ * drops it, and an `endpoint` frame is not. Optional, because a transport test drives this
314
+ * function with no resilience installed.
315
+ */
316
+ onUsableSession = () => undefined) {
308
317
  return new Promise((_resolve, reject) => {
309
318
  // A client just started us, which is the only honest evidence that Reticle is registered with
310
319
  // one at all: a config entry proves somebody wrote JSON, not that a client ever read it. Paired
@@ -452,13 +461,11 @@ exitProcess = (code) => process.exit(code)) {
452
461
  const msg = parseJsonRpc(line);
453
462
  if (null === msg || msg.id === undefined || !pending.take(msg.id))
454
463
  return;
455
- if (failure.transport) {
456
- reportMcpOutage(OutageStage.FIRST, {
457
- reason: OutageReason.CONNECT_ERROR,
458
- attempts: failure.attempts,
459
- pendingLost: 1,
460
- });
461
- }
464
+ // No outage is reported here, deliberately. A POST leg that died while the SSE stream is
465
+ // still up is not the agent losing its tools: `postSocketFailures` on the session summary
466
+ // already counts it, and reporting it as a lost stream both double-counted and — because
467
+ // the outage cap is per process — let a transient POST failure in the first minute suppress
468
+ // the real stream outage in the fortieth.
462
469
  proxyLog('reticle_mcp_proxy_post_unanswered', {
463
470
  port,
464
471
  method: String(msg.method),
@@ -490,6 +497,10 @@ exitProcess = (code) => process.exit(code)) {
490
497
  reportMcpOutage(OutageStage.RECOVERED, { reason: lastDropReason, attempts: cost });
491
498
  attempts = 0;
492
499
  attemptsBeforeDormant = 0;
500
+ // The disconnects behind us were reconnects, not a runaway. Counting them for the life of
501
+ // the process made twenty daemon restarts over a working week indistinguishable from twenty
502
+ // in eight seconds, and the proxy left on the former.
503
+ onUsableSession();
493
504
  // The new session's McpServer has never seen the client's initialize — replay it first, then
494
505
  // flush whatever the client sent while we were reconnecting.
495
506
  for (const line of replay.replayLines())
@@ -12,7 +12,16 @@ export declare const CALL_TOOL_METHOD = "tools/call";
12
12
  * Non-session-bound tools (and tools with no outputSchema) are returned unchanged.
13
13
  */
14
14
  export declare function withSessionEnvelope(name: string, outputSchema: z.ZodRawShape | undefined): z.ZodRawShape | undefined;
15
- export declare function firstSentence(description: string): string;
15
+ /**
16
+ * Abbreviations whose internal period is NOT a sentence end.
17
+ *
18
+ * The splitter looked for the first `". "`, which "e.g. " satisfies — so every lean-profile
19
+ * description containing one was cut off mid-abbreviation. `reticle_act`'s ref parameter reached the
20
+ * agent as "Element ref from reticle_snapshot or reticle_query (e.g." and stopped: the example gone,
21
+ * and any contract stated after it gone too. Silent, and it degraded the DEFAULT profile only, which
22
+ * is the one nobody reads the raw strings for.
23
+ */
24
+ export { firstSentence } from '../tools/first-sentence.js';
16
25
  /**
17
26
  * Serialize a tool result to the MCP `text` content block. The agent consumes this text — the
18
27
  * typed contract travels separately as `structuredContent`, unchanged by encoding — so the text
@@ -36,6 +36,7 @@ import { log } from '../../log.js';
36
36
  import { SERVER_VERSION } from '../../command/version/identity/server-version.js';
37
37
  import { setMcpClientNameHook } from '../../telemetry/feedback-context.js';
38
38
  import { getSessionMetrics } from '../../telemetry/session-metrics.js';
39
+ import { reportMcpConnected } from '../../telemetry/mcp-connection.js';
39
40
  import { parsePredicate } from '@reticlehq/engine/question/predicate/predicate-parse.js';
40
41
  /**
41
42
  * Merge the runtime-spliced envelope (health/lease/age/control) into a session-bound tool's declared
@@ -70,16 +71,9 @@ function withExample(description, example) {
70
71
  * and any contract stated after it gone too. Silent, and it degraded the DEFAULT profile only, which
71
72
  * is the one nobody reads the raw strings for.
72
73
  */
73
- const ABBREVIATIONS = ['e.g.', 'i.e.', 'etc.', 'vs.', 'cf.'];
74
- export function firstSentence(description) {
75
- const nl = description.indexOf('\n');
76
- const base = nl >= 0 ? description.slice(0, nl) : description;
77
- // Mask abbreviations with an equal-length filler so offsets stay valid in the original string.
78
- const masked = ABBREVIATIONS.reduce((text, abbr) => text.split(abbr).join('\u0000'.repeat(abbr.length)), base);
79
- const dot = masked.search(/\.\s/);
80
- const sentence = dot >= 0 ? base.slice(0, dot + 1) : base;
81
- return sentence.length > 160 ? `${sentence.slice(0, 159)}…` : sentence;
82
- }
74
+ // Re-exported because callers (and `mcp.test.ts`) have imported it from here since it lived here.
75
+ export { firstSentence } from '../tools/first-sentence.js';
76
+ import { firstSentence } from '../tools/first-sentence.js';
83
77
  /**
84
78
  * Parameters carrying the recursive predicate grammar. Advertised compactly in lean profiles.
85
79
  *
@@ -124,8 +118,8 @@ const PREDICATE_KINDS = 'Predicate object: { kind, ...fields }. kind is one of e
124
118
  * paying for six.
125
119
  */
126
120
  const PREDICATE_FIELD_GRAMMAR_HINT = ' Bug-catching options: net.count (exact request count — catches double-submit), ' +
127
- 'net.bodyContains (a substring of what the SERVER answered — catches a UI that echoes the input ' +
128
- 'it sent instead of the result it got back), net.requestBodyMatches (a shallow JSON match on what ' +
121
+ 'net.bodyMatches (a shallow JSON match on what the SERVER answered — say the FIELD and its value, ' +
122
+ 'because a substring of the body matches key names too), net.requestBodyMatches (the same on what ' +
129
123
  'the UI SENT — the verdict for "applying this filter actually puts it in the payload"), ' +
130
124
  'console.absent:true (action completed with a CLEAN ' +
131
125
  'console), absent:true on element/text (it should be gone). Call reticle_tools for the full field ' +
@@ -218,21 +212,33 @@ tools = tableForSurface(surface)) {
218
212
  const shipped = new Set(tableForSurface(surface).map((tool) => tool.name));
219
213
  const ours = tools.filter((tool) => shipped.has(tool.name));
220
214
  const theirs = tools.filter((tool) => !shipped.has(tool.name));
221
- // The `merged` surface keeps `reticle_tools` and drops `reticle_run`, which is not symmetry for
222
- // its own sake. `reticle_run` exists to DISPATCH to a tool the surface does not advertise, and
223
- // this surface advertises everything, so it has no job left. `reticle_tools` has a second job that
224
- // survives: every input schema on a trimmed surface is LEAN, and `{ names: [...] }` is the only
225
- // way to get a tool's full parameters — which matters more here than anywhere, because a merged
226
- // tool's schema is the union of its members' fields with all of them optional. Recovery messages
227
- // across this server also say "Call reticle_tools", and a surface without it turns that advice
228
- // into a dead end (see surface-sizes.test.ts).
229
- // A surface with no `reticle_run` can invoke only what it advertises, so the catalogue is told to
230
- // list exactly that. Anything else is a menu of names the agent cannot order from.
215
+ /*
216
+ * `merged` used to drop `reticle_run`, on the reasoning that the hatch "exists to DISPATCH to a
217
+ * tool the surface does not advertise, and this surface advertises everything, so it has no job
218
+ * left".
219
+ *
220
+ * THE PREMISE WAS FALSE, and it had been false for some time. `MERGED_TOOL_NAMES` is
221
+ * `CORE_TOOL_NAMES` minus the merged-away names plus `LOOK`; `EXTENDED_TOOL_NAMES` — screenshot,
222
+ * visual diff, clock, network mock, storage, record, the flow pair, intent, context, capabilities
223
+ * — is not in it. So eleven registered tools were advertised by nothing, callable by nothing, and
224
+ * catalogued by nothing: `reticle_tools { names: ["reticle_screenshot"] }` answered `unknown
225
+ * tool`, on a build where the tool exists and is registered.
226
+ *
227
+ * The extended set's own comments say each demoted tool is "still one `reticle_run` hop from any
228
+ * agent that wants it". That is the trade the demotion was justified by, and dropping the hatch
229
+ * silently cancelled the half of it we owed the caller. One of the casualties was
230
+ * `reticle_visual_diff`, which covers the only bug class the registry marks as one Reticle cannot
231
+ * otherwise see.
232
+ *
233
+ * So the hatch stays, and the catalogue lists everything the hatch can reach. `reticle_tools`
234
+ * keeps its second job either way: every input schema on a trimmed surface is LEAN, and
235
+ * `{ names: [...] }` is the only way to get full parameters — which matters most here, because a
236
+ * merged tool's schema is the union of its members' fields with all of them optional. Recovery
237
+ * messages across this server also say "Call reticle_tools", and a surface without it turns that
238
+ * advice into a dead end (see surface-sizes.test.ts).
239
+ */
231
240
  const advertisedHere = filterTools([...ours], surface);
232
- const callable = surface === TOOL_SURFACE.MERGED
233
- ? new Set([...advertisedHere, ...theirs].map((tool) => tool.name))
234
- : undefined;
235
- const meta = buildDynamicTools([...tools], origin, callable).filter((tool) => surface !== TOOL_SURFACE.MERGED || tool.name !== ReticleTool.RUN);
241
+ const meta = buildDynamicTools([...tools], origin, undefined);
236
242
  return [...advertisedHere, ...theirs, ...meta];
237
243
  }
238
244
  /**
@@ -512,8 +518,14 @@ tools = tableForSurface(profile)) {
512
518
  */
513
519
  server.server.oninitialized = () => {
514
520
  const info = server.server.getClientVersion();
515
- if (info?.name !== undefined)
516
- getSessionMetrics().recordClient(info.name, info.version);
521
+ if (info?.name === undefined)
522
+ return;
523
+ getSessionMetrics().recordClient(info.name, info.version);
524
+ // The one signal that separates "Reticle is running" from "somebody is USING it", reported from
525
+ // the first moment it can carry WHO. It used to fire from the SSE `connect()` resolution, which
526
+ // happens before the handshake — so every connect row was anonymous, and the field meant to say
527
+ // which agent client converts best was empty on every event ever sent.
528
+ reportMcpConnected(info.name);
517
529
  };
518
530
  // Which agent is on the other end, for a feedback report. Registered as a lazy hook rather than
519
531
  // read here: the handshake has not happened yet at construction time, and a report filed twenty
@@ -540,7 +552,25 @@ tools = tableForSurface(profile)) {
540
552
  const advertised = advertisedTools(profile, tools);
541
553
  // The names this surface actually hands the agent. Every piece of advice leaving this server is
542
554
  // rewritten against it, so a static string cannot route a reader to a tool they were not given.
543
- const advertisedNames = new Set(advertised.map((tool) => tool.name));
555
+ /**
556
+ * What a name in advice can actually REACH, which is not the same as what is advertised.
557
+ *
558
+ * `liveCallText` rewrites mentions of tools this surface cannot reach — it exists because advice
559
+ * naming `reticle_flow_save` on a surface without it sent agents at a tool that answered "not
560
+ * reachable on this tool surface". Gated on the ADVERTISED set, it kept firing once the dispatch
561
+ * hatch came back: `reticle_flow_save` and `reticle_record` are reachable through
562
+ * `reticle_run` but not advertised, so every mention of them — INCLUDING their own names and the
563
+ * parameter docs that name the recording to save — was rewritten to
564
+ * `reticle_verify { action: "explore", persona }`. Two tools with the same wrong name, and a
565
+ * `flowName` description telling the reader to pass the name that a tool which is not the one
566
+ * they called was called with.
567
+ *
568
+ * With a hatch, every registered tool is reachable and no rewriting is owed. Without one,
569
+ * advertised IS reachable, which is the case this was written for.
570
+ */
571
+ const advertisedNames = advertised.some((tool) => ReticleTool.RUN === tool.name)
572
+ ? new Set(tools.map((tool) => tool.name))
573
+ : new Set(advertised.map((tool) => tool.name));
544
574
  installFriendlyArgErrors(server, new Map(advertised
545
575
  .filter((tool) => tool.example !== undefined)
546
576
  .map((tool) => [tool.name, JSON.stringify(tool.example)])),
@@ -5,5 +5,18 @@ export declare function daemonPollDelayMs(attempt: number): number;
5
5
  * inside the daemon just to check reachability.
6
6
  */
7
7
  export declare function probeDaemon(port: number): Promise<boolean>;
8
- /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
9
- export declare function waitForDaemon(port: number): Promise<void>;
8
+ /**
9
+ * Poll until a Reticle daemon is SERVING on `port`, or the deadline passes.
10
+ *
11
+ * "Accepts a TCP connection" is not the question. A daemon wedged mid-start accepts and never
12
+ * serves, and so does a stranger holding the port — `port-presence.ts` calls both FOREIGN for
13
+ * exactly this reason, and every other decision point in the codebase was migrated onto it. This
14
+ * one was not, so the wake path spawned a daemon, waited on a bare connect, and dialled a corpse.
15
+ *
16
+ * Probes are injected (defaulting to the real pair) so the rule is testable without a socket, which
17
+ * is the same shape `probePresence`'s own callers use.
18
+ */
19
+ export declare function waitForDaemon(port: number, probes?: {
20
+ tcpOpen: (port: number) => Promise<boolean>;
21
+ status: (port: number) => Promise<unknown>;
22
+ }): Promise<void>;
@@ -1,5 +1,7 @@
1
1
  import * as net from 'node:net';
2
2
  import { LOOPBACK_HOST } from '@reticlehq/core';
3
+ import { describePresence, presenceIsUsable, probePresence, PortPresence, } from '../../../command/daemon/binding/port-presence.js';
4
+ import { fetchStatus } from '../../../command/daemon/binding/daemon-status-probe.js';
3
5
  const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
4
6
  /**
5
7
  * How long to wait for the spawned daemon's port to accept connections before giving up. The default
@@ -39,16 +41,31 @@ export function probeDaemon(port) {
39
41
  socket.connect(port, LOOPBACK_HOST);
40
42
  });
41
43
  }
42
- /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
43
- export async function waitForDaemon(port) {
44
+ /**
45
+ * Poll until a Reticle daemon is SERVING on `port`, or the deadline passes.
46
+ *
47
+ * "Accepts a TCP connection" is not the question. A daemon wedged mid-start accepts and never
48
+ * serves, and so does a stranger holding the port — `port-presence.ts` calls both FOREIGN for
49
+ * exactly this reason, and every other decision point in the codebase was migrated onto it. This
50
+ * one was not, so the wake path spawned a daemon, waited on a bare connect, and dialled a corpse.
51
+ *
52
+ * Probes are injected (defaulting to the real pair) so the rule is testable without a socket, which
53
+ * is the same shape `probePresence`'s own callers use.
54
+ */
55
+ export async function waitForDaemon(port, probes = { tcpOpen: probeDaemon, status: fetchStatus }) {
44
56
  const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
45
57
  let attempt = 0;
58
+ let presence = PortPresence.FREE;
46
59
  while (Date.now() < deadline) {
47
- const reachable = await probeDaemon(port);
48
- if (reachable)
60
+ presence = await probePresence(port, probes);
61
+ if (presenceIsUsable(presence))
49
62
  return;
50
63
  attempt++;
51
64
  await delay(daemonPollDelayMs(attempt));
52
65
  }
53
- throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
66
+ // The state we timed out in is the whole diagnosis — FREE means the daemon never came up, FOREIGN
67
+ // means something is squatting the port. Reporting only the timeout sent readers hunting the
68
+ // wrong one.
69
+ throw new Error(`reticle daemon did not become ready on port ${String(port)} within ` +
70
+ `${String(DAEMON_READY_TIMEOUT_MS)}ms — ${describePresence(presence, port)}`);
54
71
  }
@@ -27,18 +27,12 @@ export declare function accountProxyLogWrite(bytesSinceCheck: number, written: n
27
27
  /**
28
28
  * Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
29
29
  *
30
- * An uncapped proxy log has filled a disk and broken unrelated builds, Docker and ordinary shell
31
- * commands with ENOSPC. Nothing in Reticle degrades first, so the failure surfaces as the operating
32
- * system refusing to write files. Such files already exist on machines that ran an older build, and
33
- * the cap alone does not help them: nobody should have to find one with `du`.
30
+ * Path-shaped wrapper over `recoverOversizedLog`, which is where the rule, the in-place truncation
31
+ * and the ENOSPC incident behind them are written down. Both of Reticle's logs are capped by that
32
+ * one function; this keeps the name and the `fileSize`/`truncateFile` deps its callers already pass.
34
33
  *
35
- * TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
36
- * and a file that a running process still holds open keeps its blocks allocated after an unlink
37
- * until that handle closes — which is why `: > ~/.reticle/proxy-4400.log` is the operation to copy
38
- * here.
39
- *
40
- * Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
41
- * housekeeping is strictly worse than a large file.
34
+ * Such files already exist on machines that ran an older build, and the cap alone does not help
35
+ * them: nobody should have to find one with `du`.
42
36
  */
43
37
  export declare function recoverOversizedProxyLog(path: string, deps: {
44
38
  fileSize(p: string): number;
@@ -10,7 +10,7 @@ import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { ReticleDir } from '@reticlehq/core';
12
12
  import { log } from '../../../log.js';
13
- import { MAX_DAEMON_LOG_BYTES, rotateDaemonLog } from '../../../command/daemon/daemon.js';
13
+ import { MAX_DAEMON_LOG_BYTES, recoverOversizedLog, rotateDaemonLog, } from '../../../command/daemon/daemon.js';
14
14
  /**
15
15
  * Which port this proxy serves, for the log file name. Set once at startup.
16
16
  *
@@ -46,30 +46,15 @@ export function accountProxyLogWrite(bytesSinceCheck, written, path, deps) {
46
46
  /**
47
47
  * Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
48
48
  *
49
- * An uncapped proxy log has filled a disk and broken unrelated builds, Docker and ordinary shell
50
- * commands with ENOSPC. Nothing in Reticle degrades first, so the failure surfaces as the operating
51
- * system refusing to write files. Such files already exist on machines that ran an older build, and
52
- * the cap alone does not help them: nobody should have to find one with `du`.
49
+ * Path-shaped wrapper over `recoverOversizedLog`, which is where the rule, the in-place truncation
50
+ * and the ENOSPC incident behind them are written down. Both of Reticle's logs are capped by that
51
+ * one function; this keeps the name and the `fileSize`/`truncateFile` deps its callers already pass.
53
52
  *
54
- * TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
55
- * and a file that a running process still holds open keeps its blocks allocated after an unlink
56
- * until that handle closes — which is why `: > ~/.reticle/proxy-4400.log` is the operation to copy
57
- * here.
58
- *
59
- * Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
60
- * housekeeping is strictly worse than a large file.
53
+ * Such files already exist on machines that ran an older build, and the cap alone does not help
54
+ * them: nobody should have to find one with `du`.
61
55
  */
62
56
  export function recoverOversizedProxyLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
63
- try {
64
- const size = deps.fileSize(path);
65
- if (max >= size)
66
- return 0;
67
- deps.truncateFile(path);
68
- return size;
69
- }
70
- catch {
71
- return 0;
72
- }
57
+ return recoverOversizedLog({ size: () => deps.fileSize(path), truncate: () => deps.truncateFile(path) }, max);
73
58
  }
74
59
  /** The real filesystem behind the two rotation helpers. */
75
60
  const proxyLogFileOps = {
@@ -14,7 +14,7 @@ import { PredicateSchema } from '@reticlehq/engine/question/predicate/predicate-
14
14
  import { isGlobalPressCall } from '@reticlehq/core';
15
15
  import { SessionReplacedError } from '../../../portal/session/page-commands/pending-commands.js';
16
16
  import { assertNativeInputSupported } from './act-danger.js';
17
- import { unevaluablePredicateReason } from '@reticlehq/engine/question/predicate/predicate-precheck.js';
17
+ import { unevaluablePredicateReason, vacuousPredicateReason, } from '@reticlehq/engine/question/predicate/predicate-precheck.js';
18
18
  /**
19
19
  * Refuse a sequence whose steps cannot be addressed.
20
20
  *
@@ -137,6 +137,11 @@ export function preflightAct(actArgs, until) {
137
137
  const unevaluable = unevaluablePredicateReason(until);
138
138
  if (unevaluable !== undefined)
139
139
  throw new Error(unevaluable);
140
+ // A predicate that was already true when it was written answered `no-fault` / `already_true` —
141
+ // verdict-shaped, and carrying no proof of anything the action did. See vacuousPredicateReason.
142
+ const vacuous = vacuousPredicateReason(until);
143
+ if (vacuous !== undefined)
144
+ throw new Error(vacuous);
140
145
  }
141
146
  /**
142
147
  * Dispatch a write, or report that its outcome went unobserved.
@@ -39,6 +39,19 @@ export function gradeSequence(steps, plan = { planned: steps.length, dispatched:
39
39
  because: `a declared consequence did not hold — observed ${observed}`,
40
40
  };
41
41
  }
42
+ /*
43
+ * `unknown` rather than `no-fault`, and the distinction is load-bearing.
44
+ *
45
+ * "Nothing was declared" is what `no-fault` means for a single action -- but the engine only says
46
+ * it over a SETTLED window, because no-fault claims the whole window was observed. Nothing on
47
+ * this path observes settledness: the caller tracks dispatch and stalls, never settle. Saying
48
+ * `no-fault` here would claim evidence nobody collected, in the one place this product cannot
49
+ * afford to overclaim.
50
+ *
51
+ * So the enum stays conservative and the SENTENCE carries the whole answer: it names declaring a
52
+ * consequence as the next move, which is what an agent reading `no-fault` would have done.
53
+ * Upgrading means plumbing `settled` from the act results through `SequencePlan`.
54
+ */
42
55
  if (0 === declared) {
43
56
  return {
44
57
  verified: Verified.UNKNOWN,
@@ -27,7 +27,12 @@ export async function saveFailedAssertCapsule(inputs) {
27
27
  // Same rule and same resolver as every other artifact: a capsule written into a sibling repo is
28
28
  // evidence filed against a codebase that did not produce it, and it outlives the turn.
29
29
  // The caller's answer wins; the resolver is the fallback for callers that have no session.
30
- const root = given ?? sessionRoot(deps, asString(args['sessionId']));
30
+ const root = given ?? bestEffortRoot(deps, asString(args['sessionId']));
31
+ // No directory this capsule could honestly belong to, so it is not filed. The two things this
32
+ // must not do are write it somewhere arbitrary and throw: the assertion has already failed, the
33
+ // verdict is already built, and losing the capsule must never also lose the verdict.
34
+ if (root === undefined)
35
+ return undefined;
31
36
  const saved = await new CapsuleStore(deps.fs, root).save({
32
37
  version: CAPSULE_VERSION,
33
38
  id,
@@ -51,3 +56,24 @@ export async function saveFailedAssertCapsule(inputs) {
51
56
  });
52
57
  return saved ? id : undefined;
53
58
  }
59
+ /**
60
+ * Where this capsule goes, or nothing — never a throw and never somebody else's checkout.
61
+ *
62
+ * `sessionRoot` refuses a `sessionId` that names no session, because for a tool the agent CALLED
63
+ * the alternative is silently writing into the daemon's own project. Here the caller is not a tool
64
+ * the agent called: it is the evidence capture hanging off a verdict that has already been decided,
65
+ * and on this path a refusal would convert a red assertion into a tool error. Both of the answers
66
+ * that refusal exists to prevent are still prevented — nothing is written, and nothing is written
67
+ * to the wrong root — by declining to file the capsule at all.
68
+ *
69
+ * Reachable only when the act's own session AND its navigation successor are gone by the time the
70
+ * verdict lands, since `act_and_wait` passes the root of the session it actually drove.
71
+ */
72
+ function bestEffortRoot(deps, sessionId) {
73
+ try {
74
+ return sessionRoot(deps, sessionId);
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }