@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
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Who is holding the bridge port.
3
+ *
4
+ * `doctor` is what a user runs when the agent cannot reach the bridge, and the commonest real cause
5
+ * is that something else is on 4400. It already stopped saying "nothing is listening" for that case,
6
+ * but it did not say WHICH process — so the reader's next move was a shell command they had to know.
7
+ *
8
+ * Naming the holder is also the safe answer to a trap this project has hit for real: the obvious
9
+ * lookup, `lsof -ti tcp:4400 | xargs kill -9`, ALSO kills the agent's own `reticle mcp` proxy,
10
+ * because that proxy holds a client connection to the same port and `-ti` does not filter to
11
+ * listeners. Every invocation here is `-sTCP:LISTEN`, and the message never suggests `-ti`.
12
+ *
13
+ * The parser is pure and the lookup takes an injected exec, so the rule is tested on every platform
14
+ * while the shelling out stays at the call site.
15
+ */
16
+ /** `lsof -F pc` emits one field per line, prefixed by its selector: `p<pid>`, `c<command>`. */
17
+ const PID_FIELD = 'p';
18
+ const COMMAND_FIELD = 'c';
19
+ /**
20
+ * The FIRST listener in `lsof -nP -iTCP:<port> -sTCP:LISTEN -F pc` output, or null.
21
+ *
22
+ * First, not "the only one": a dual-stack listener (IPv4 + IPv6) is reported twice, and on a port
23
+ * conflict the answer the reader needs is "something is there, here is what" rather than an
24
+ * exhaustive list. A second `p` record ends the first.
25
+ *
26
+ * Null on anything incomplete. Half an answer — a pid with no command — prints worse than the
27
+ * un-named message it would replace, which is the same mistake as the port lie this command fixed.
28
+ */
29
+ export function parsePortHolder(stdout) {
30
+ let pid;
31
+ let command;
32
+ for (const raw of stdout.split('\n')) {
33
+ const line = raw.trim();
34
+ if (0 === line.length)
35
+ continue;
36
+ const value = line.slice(1);
37
+ if (line.startsWith(PID_FIELD)) {
38
+ if (pid !== undefined)
39
+ break; // a second record: the first one is the answer
40
+ const parsed = Number(value);
41
+ if (!Number.isInteger(parsed) || parsed <= 0)
42
+ return null;
43
+ pid = parsed;
44
+ continue;
45
+ }
46
+ if (line.startsWith(COMMAND_FIELD) && command === undefined)
47
+ command = value;
48
+ }
49
+ return pid === undefined || command === undefined ? null : { pid, command };
50
+ }
51
+ /** What `doctor` prints for a port held by something that is not a Reticle daemon. */
52
+ export function describeForeignHolder(port, holder,
53
+ /**
54
+ * The pid we recorded for this port, from the daemon pid file.
55
+ *
56
+ * When it matches the process actually holding the port, the port is held by OUR daemon — one
57
+ * that accepts TCP and never answers `/status`, i.e. wedged. That classifies as FOREIGN, which is
58
+ * right for "can anything bind this?" and wrong to say out loud: the reader goes hunting a
59
+ * stranger, and the fix that works (`reticle stop`, it respawns on the next call) is never named.
60
+ */
61
+ ourPid) {
62
+ const tail = 'a daemon cannot bind it. Stop that process, or run Reticle on a different port ' +
63
+ '(`--port`), then retry.';
64
+ if (null === holder) {
65
+ /**
66
+ * We could not identify the holder — no `lsof` (every Windows user, and 35% of them by the
67
+ * 2026-08 telemetry; also a slim container), or the lookup timed out.
68
+ *
69
+ * This used to assert the port was held by "another process that is not a Reticle daemon",
70
+ * which is a claim the null case has NO evidence for. It is also the likeliest thing to be
71
+ * wrong: reaching here at all means presence is FOREIGN — the port accepts TCP and never
72
+ * answers `/status` — and the commonest cause of that is OUR OWN daemon, wedged. So on the one
73
+ * platform where the lookup cannot run, `doctor` sent the reader hunting a stranger and never
74
+ * named `reticle stop`, the fix that actually works.
75
+ *
76
+ * Say what is known, and name the recorded pid when there is one.
77
+ */
78
+ const ours = ourPid !== null && ourPid !== undefined
79
+ ? ` Reticle's own records show pid ${String(ourPid)} for this port — if that process is ` +
80
+ 'still around it is a wedged Reticle daemon, and `reticle stop` clears it.'
81
+ : '';
82
+ return `port ${String(port)} is held by a process Reticle could not identify — ${tail}${ours}`;
83
+ }
84
+ if (ourPid !== null && ourPid !== undefined && ourPid === holder.pid) {
85
+ return (`port ${String(port)} is held by YOUR Reticle daemon (pid ${String(holder.pid)}), and it is ` +
86
+ 'not responding — running but wedged, so nothing can use it and nothing else can bind the ' +
87
+ 'port. Stop it with `reticle stop`; the next tool call starts a fresh one.');
88
+ }
89
+ return (`port ${String(port)} is held by pid ${String(holder.pid)} ("${holder.command}"), which is not ` +
90
+ `a Reticle daemon — ${tail}`);
91
+ }
92
+ /** The lookup itself. `exec` is injected so the shelling out never runs in a unit test. */
93
+ export function findPortHolder(port, exec) {
94
+ // -n/-P skip DNS and service-name lookups (slow, and irrelevant here). -sTCP:LISTEN is the part
95
+ // that matters: without it this also reports the agent's own MCP proxy, which is a client of the
96
+ // port, not its owner.
97
+ const out = exec('lsof', ['-nP', `-iTCP:${String(port)}`, '-sTCP:LISTEN', '-F', 'pc']);
98
+ return null === out ? null : parsePortHolder(out);
99
+ }
package/dist/cli.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { realpathSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
+ import { stateDirProblem } from './daemon/state-dir.js';
6
+ import { reticleStateHome } from './daemon/daemon.js';
5
7
  import { handleWatch, handleCapsules, handleGate, loadNamedFlows, resolveChangedFiles, } from './cli/cli-flow-commands.js';
6
8
  import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
7
9
  import { loadDotEnv } from './telemetry/dev-repo.js';
@@ -13,10 +15,13 @@ import { start, startDaemon } from './index.js';
13
15
  import { isCloudCommand, runCloudCommand } from './cli/cloud-cli.js';
14
16
  import { SERVER_VERSION } from './version/server-version.js';
15
17
  import { log } from './log.js';
16
- import { readPid, isAlive, isRunning, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry, } from './daemon/daemon.js';
18
+ import { readPid, isAlive, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry, logPath, } from './daemon/daemon.js';
19
+ import { PortPresence, probePresence, presenceIsUsable, describePresence, } from './daemon/port-presence.js';
17
20
  import { waitForDaemon, startMcpProxy, probeDaemon, proxyLog, setProxyLogPort, } from './mcp/mcp-proxy.js';
18
- import { installDaemonResilience, installProxyResilience } from './daemon/daemon-resilience.js';
21
+ import { installDaemonResilience, installProxyResilience, recordExitReason, DaemonExitReason, } from './daemon/daemon-resilience.js';
19
22
  import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
23
+ import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
24
+ import { everServedToolCall } from './daemon/daemon-usefulness.js';
20
25
  import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, } from './cli/cli-launch.js';
21
26
  import { handleVerify } from './cli/cli-verify.js';
22
27
  import { summarizeHunt } from './hunt/hunt-report.js';
@@ -45,10 +50,38 @@ function handleInit(parsed) {
45
50
  process.exit(1);
46
51
  }
47
52
  function handleServe(parsed) {
48
- if (isRunning(parsed.port)) {
53
+ void serveWithHonestExit(parsed);
54
+ }
55
+ /**
56
+ * Report the BIND, not the spawn.
57
+ *
58
+ * `serve` used to log `reticle_daemon_spawned` and exit 0 unconditionally, while the child died at
59
+ * `reticle_daemon_start_failed` with an EADDRINUSE nothing joined back to the parent. Three surfaces
60
+ * then disagreed about what was true and none of them named the port.
61
+ *
62
+ * Two changes, both about saying what is actually the case: refuse up front when the port is held by
63
+ * something that is not a Reticle daemon (a spawn there cannot succeed), and after spawning, wait
64
+ * for the daemon to actually answer before claiming it started.
65
+ */
66
+ async function serveWithHonestExit(parsed) {
67
+ const presence = await probePresence(parsed.port, {
68
+ tcpOpen: probeDaemon,
69
+ status: fetchStatus,
70
+ });
71
+ if (presence === PortPresence.DAEMON) {
49
72
  log('reticle_daemon_already_running', { port: parsed.port });
50
73
  return;
51
74
  }
75
+ if (presence === PortPresence.FOREIGN) {
76
+ log('reticle_daemon_start_refused', {
77
+ port: parsed.port,
78
+ presence,
79
+ reason: describePresence(presence, parsed.port),
80
+ });
81
+ process.stderr.write(`${describePresence(presence, parsed.port)}\n`);
82
+ process.exit(1);
83
+ return;
84
+ }
52
85
  const scriptPath = process.argv[1];
53
86
  if (scriptPath === undefined) {
54
87
  log('reticle_serve_no_script', {});
@@ -69,8 +102,51 @@ function handleServe(parsed) {
69
102
  daemonArgs.push(HTTP_TOKEN_FLAG, parsed.httpToken);
70
103
  }
71
104
  spawnDaemon(process.execPath, scriptPath, daemonArgs, parsed.port);
105
+ // The child binds asynchronously and, when it cannot, exits 1 long after this process would have
106
+ // reported success. Wait for it to ANSWER — `/status` responding is the only evidence a daemon
107
+ // exists that does not come from the pid file the child may never have earned.
108
+ const bound = await waitForPresence(parsed.port, PortPresence.DAEMON, SERVE_BIND_TIMEOUT_MS);
109
+ if (!bound) {
110
+ const settled = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
111
+ log('reticle_daemon_start_failed_parent', {
112
+ port: parsed.port,
113
+ presence: settled,
114
+ reason: describePresence(settled, parsed.port),
115
+ log: logPath(parsed.port),
116
+ stateDir: stateDirProblem(reticleStateHome()) === undefined ? 'writable' : 'unwritable',
117
+ });
118
+ // Name the CAUSE when we can see it. An unwritable state directory is a first-run failure mode
119
+ // (locked-down home, read-only container mount, managed profile) that produced only "nothing is
120
+ // listening" — the symptom restated — and then pointed at a log INSIDE that directory, which
121
+ // cannot exist. Found by stress-testing 2.6.0 with `chmod 555 ~/.reticle`.
122
+ const dirProblem = stateDirProblem(reticleStateHome());
123
+ process.stderr.write(dirProblem === undefined
124
+ ? `the daemon did not come up on :${String(parsed.port)} — ${describePresence(settled, parsed.port)}\n` +
125
+ `see ${logPath(parsed.port)}\n`
126
+ : `the daemon did not come up on :${String(parsed.port)}.\n${dirProblem}\n`);
127
+ process.exit(1);
128
+ return;
129
+ }
72
130
  log('reticle_daemon_spawned', { port: parsed.port, ...(parsed.http ? { http: true } : {}) });
73
131
  }
132
+ /** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
133
+ const SERVE_BIND_TIMEOUT_MS = 15_000;
134
+ const PRESENCE_POLL_MS = 150;
135
+ /** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
136
+ const GRACEFUL_STOP_MS = 5000;
137
+ /** How long after SIGKILL before we accept the process is not ours to kill and say so. */
138
+ const FORCED_STOP_MS = 2000;
139
+ async function waitForPresence(port, want, timeoutMs) {
140
+ const deadline = Date.now() + timeoutMs;
141
+ for (;;) {
142
+ const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
143
+ if (presence === want)
144
+ return true;
145
+ if (Date.now() >= deadline)
146
+ return false;
147
+ await new Promise((resolve) => setTimeout(resolve, PRESENCE_POLL_MS));
148
+ }
149
+ }
74
150
  function handleStop(port, quiet) {
75
151
  const pid = readPid(port);
76
152
  if (null === pid || !isAlive(pid)) {
@@ -81,18 +157,35 @@ function handleStop(port, quiet) {
81
157
  }
82
158
  process.kill(pid, 'SIGTERM');
83
159
  const started = Date.now();
160
+ let escalated = false;
84
161
  const poll = setInterval(() => {
85
162
  if (!isAlive(pid)) {
86
163
  clearInterval(poll);
87
164
  removePid(port);
88
165
  if (!quiet)
89
- log('reticle_daemon_stopped', { port, pid });
166
+ log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
167
+ return;
168
+ }
169
+ const waited = Date.now() - started;
170
+ // A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
171
+ // up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
172
+ // first two minutes of setup. Escalate instead: graceful first, then unconditional.
173
+ if (!escalated && waited > GRACEFUL_STOP_MS) {
174
+ escalated = true;
175
+ try {
176
+ process.kill(pid, 'SIGKILL');
177
+ }
178
+ catch {
179
+ // Already gone between the liveness check and here — the next tick reports it stopped.
180
+ }
90
181
  return;
91
182
  }
92
- if (Date.now() - started > 5000) {
183
+ if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
93
184
  clearInterval(poll);
185
+ // Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
186
+ // zombie. Neither is retryable, and both need the pid named so a human can act on it.
94
187
  if (!quiet)
95
- log('reticle_daemon_stop_timeout', { port, pid });
188
+ log('reticle_daemon_stop_timeout', { port, pid, escalated });
96
189
  process.exit(1);
97
190
  }
98
191
  }, 100);
@@ -100,7 +193,16 @@ function handleStop(port, quiet) {
100
193
  function handleStatus(port) {
101
194
  const pid = readPid(port);
102
195
  if (null === pid || !isAlive(pid)) {
103
- log('reticle_status', { port, running: false });
196
+ // `running: false` on its own has been reported about a port that was demonstrably occupied,
197
+ // because the pid file is not the port. Ask the port before answering.
198
+ void probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then((presence) => {
199
+ log('reticle_status', {
200
+ port,
201
+ running: presenceIsUsable(presence),
202
+ presence,
203
+ ...(presence === PortPresence.FOREIGN ? { reason: describePresence(presence, port) } : {}),
204
+ });
205
+ });
104
206
  return;
105
207
  }
106
208
  // The daemon is up — ask it for live sessions + health so status is at-a-glance, not just a pid.
@@ -279,11 +381,21 @@ function handleOpen(requestedPort, url) {
279
381
  ...(connected
280
382
  ? {}
281
383
  : {
282
- note: 'the browser was launched but no Reticle session appeared. The app may still be ' +
283
- 'loading, or it is not wired to this bridge — run `reticle doctor`, and check the ' +
284
- 'app connects on port ' +
285
- String(port) +
286
- '.',
384
+ // Two claims used to live here that this command cannot support. It said the browser
385
+ // "was launched" — it asked the OS to open a URL and cannot see whether a window
386
+ // appeared — and it offered "the app may still be loading" as a cause with equal
387
+ // weight to the real one. It then pointed at `reticle doctor`, whose Chromium check is
388
+ // about a browser THIS command never uses, so a missing Chromium reads as the
389
+ // explanation for a session that is missing for an unrelated reason. Both misdirects
390
+ // were reported from the field, each costing several calls of app-side wiring hunt.
391
+ note: `the URL was handed to the system default browser (this command does not use ` +
392
+ `Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
393
+ `to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
394
+ `carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
395
+ `\`reticle init\` in the app's directory and restart its dev server. If the app IS ` +
396
+ `wired, give the page a moment and check the browser console: the SDK announces its ` +
397
+ `own connect failures there, including the one it refuses to make from a ` +
398
+ `non-localhost host without allowNonLocalhost.`,
287
399
  }),
288
400
  });
289
401
  })
@@ -327,12 +439,20 @@ function handleDaemonInner(parsed) {
327
439
  removePid(parsed.port);
328
440
  process.exit(1);
329
441
  });
330
- const shutdown = () => {
442
+ const shutdown = (reason) => {
443
+ // Recorded BEFORE the async chain: `installExitTrace` reads it when Node is on its way out,
444
+ // which is after everything below has run. Without it the exit line says `code: 0` and a
445
+ // reader cannot tell a tidy stop from the bridge disappearing — see #123.
446
+ recordExitReason(reason);
331
447
  // Awaited before the close/exit chain: `process.exit(0)` kills an in-flight POST, and this is
332
448
  // the one event carrying the whole session. A failed send resolves anyway (emit swallows its
333
449
  // own errors), so this can delay the exit by at most the send timeout, never prevent it.
334
450
  void daemonTelemetry
335
- .shutdown()
451
+ // The reason rides out on the session summary, which is the only event that fires at this
452
+ // exact moment. The proxy emits the matching `mcp_connection_lost` and cannot know it —
453
+ // it sees a socket end and nothing more, which is how a scheduled idle exit came to make up
454
+ // the large majority of "outages". See SessionSummary.exit.
455
+ .shutdown(reason)
336
456
  .then(() => server.close())
337
457
  .then(() => {
338
458
  removePid(parsed.port);
@@ -345,8 +465,10 @@ function handleDaemonInner(parsed) {
345
465
  process.exit(1);
346
466
  });
347
467
  };
348
- process.on('SIGTERM', shutdown);
349
- process.on('SIGINT', shutdown);
468
+ // Wrapped rather than passed directly: a signal handler receives the signal name as its first
469
+ // argument, which would otherwise arrive as the reason.
470
+ process.on('SIGTERM', () => shutdown(DaemonExitReason.SIGNAL));
471
+ process.on('SIGINT', () => shutdown(DaemonExitReason.SIGNAL));
350
472
  // Self-shut-down when idle so a detached daemon (and any headless Chromium it launched) never
351
473
  // lingers on the user's machine after the editor closes. Reuses the same clean shutdown path.
352
474
  const attachedGraceEnv = process.env[ReticleEnv.IDLE_ATTACHED];
@@ -363,10 +485,23 @@ function handleDaemonInner(parsed) {
363
485
  : { attachedGraceMs: resolveIdleShutdownMs(attachedGraceEnv) }),
364
486
  onShutdown: () => {
365
487
  log('reticle_daemon_idle_exit', { port: parsed.port });
366
- shutdown();
488
+ shutdown(DaemonExitReason.IDLE);
367
489
  },
368
490
  });
369
491
  idleShutdown.start();
492
+ // Say so, regularly, so a GAP in this log is itself evidence. `installExitTrace` hooks
493
+ // `'exit'`, which a SIGKILL never fires — so a killed daemon left nothing behind, and the one
494
+ // that exited tidily logged `code: 0`. A reader could not tell "shut down cleanly" from "the
495
+ // bridge every app on this machine needs is gone", and a correct SvelteKit install was written
496
+ // up as an install failure on exactly that ambiguity. See daemon/heartbeat.ts.
497
+ new DaemonHeartbeat({
498
+ log,
499
+ intervalMs: resolveHeartbeatMs(process.env[ReticleEnv.HEARTBEAT]),
500
+ facts: () => ({
501
+ sessions: server.bridge.sessions.count(),
502
+ served: everServedToolCall(),
503
+ }),
504
+ }).start();
370
505
  })
371
506
  .catch((error) => {
372
507
  const message = error instanceof Error ? error.message : String(error);
@@ -447,7 +582,23 @@ function handleMcp(opts) {
447
582
  })
448
583
  .finally(() => {
449
584
  // Also `void`: the proxy runs for the life of the process and is never awaited by anyone.
450
- void startMcpProxy(port, ensure);
585
+ //
586
+ // The `.catch` is load-bearing despite that. `startMcpProxy` rejects when its FIRST connect
587
+ // fails — which is precisely the case this block exists to tolerate, a daemon that is not
588
+ // there yet — and with nothing attached that reject became an unhandledRejection. Reported
589
+ // from a win32 user as a crash reading `connect ECONNREFUSED` with an EMPTY frame list,
590
+ // because the stack of a refused socket is entirely node internals and the privacy filter
591
+ // keeps only Reticle frames. So the one path we most want diagnosable arrived as an anonymous
592
+ // crash. The proxy itself is unaffected — it has already installed its stdin reader and goes
593
+ // on serving from cache, waking a daemon on the next request — which is exactly why this
594
+ // must be logged as the expected condition it is rather than reported as a defect.
595
+ void startMcpProxy(port, ensure).catch((err) => {
596
+ log('reticle_mcp_proxy_first_connect_failed', {
597
+ port,
598
+ error: err instanceof Error ? err.message : String(err),
599
+ note: 'serving from cache; the next client request will try to start a daemon',
600
+ });
601
+ });
451
602
  });
452
603
  }
453
604
  function handleLegacyDrive(parsed) {
@@ -528,7 +679,7 @@ function main() {
528
679
  handleTelemetry(parsed.action);
529
680
  break;
530
681
  case 'feedback':
531
- void handleFeedback(parsed.text, parsed.rating, parsed.bug);
682
+ void handleFeedback(parsed.text, parsed.rating, parsed.bug, parsed.feedbackKind, parsed.agent);
532
683
  break;
533
684
  case 'identify':
534
685
  void handleIdentify(parsed);
@@ -31,6 +31,30 @@ export type CrashKind = (typeof CrashKind)[keyof typeof CrashKind];
31
31
  * is the point: after this, silence in the log is itself the finding, because every other door is
32
32
  * now instrumented.
33
33
  */
34
+ /**
35
+ * Why the daemon is going away — carried ON the exit line, not left to be inferred.
36
+ *
37
+ * From the field (#123): a real gate log read `reticle_daemon_signalled SIGTERM` then
38
+ * `reticle_daemon_exiting code:0`, then 21 seconds with nothing listening. `code: 0` is the last
39
+ * line before the port goes dark and it cannot distinguish "shut down tidily" from "the bridge every
40
+ * app on this machine needs is now gone". A correct install was written up as a failure naming the
41
+ * fixture because of it.
42
+ *
43
+ * `UNKNOWN` is the load-bearing member, not a fallback: it means the process left through Node
44
+ * WITHOUT passing a shutdown path — an uncaught throw, a stray `process.exit`. That is a different
45
+ * fact from an idle exit, and the one worth noticing.
46
+ */
47
+ export declare const DaemonExitReason: {
48
+ /** A shutdown signal arrived (SIGTERM/SIGINT). */
49
+ readonly SIGNAL: "signal";
50
+ /** The idle timer fired and the daemon retired itself. */
51
+ readonly IDLE: "idle";
52
+ /** Left through Node, but not via any shutdown path we own. */
53
+ readonly UNKNOWN: "unknown";
54
+ };
55
+ export type DaemonExitReason = (typeof DaemonExitReason)[keyof typeof DaemonExitReason];
56
+ /** Record why the daemon is shutting down. Called by a shutdown path before it exits. */
57
+ export declare function recordExitReason(reason: DaemonExitReason): void;
34
58
  export declare function installExitTrace(proc: ProcessLike, log: LogFn): void;
35
59
  export declare function installDaemonResilience(proc: ProcessLike, log: LogFn, onFatal: () => void): void;
36
60
  /**
@@ -8,10 +8,11 @@
8
8
  * per Node's guidance, so LOG a clear reason and exit cleanly; the next `reticle mcp` respawns a fresh
9
9
  * daemon, which beats crashing silently or limping along corrupt.
10
10
  */
11
- import { TelemetryActor, TelemetryEventKind } from '@reticlehq/core';
11
+ import { RETICLE_DEFAULT_PORT, TelemetryActor, TelemetryEventKind } from '@reticlehq/core';
12
12
  import { errorSkeleton, errorTypeOf, fingerprintCrash, MAX_REPORTED_FRAMES, reticleFrames, } from '../telemetry/error-fingerprint.js';
13
13
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
14
14
  import { machineSnapshot } from '../telemetry/machine-snapshot.js';
15
+ import { crashCause } from '../telemetry/crash-cause.js';
15
16
  import { getTelemetry } from '../telemetry/telemetry.js';
16
17
  function describe(value) {
17
18
  return value instanceof Error ? value.message : String(value);
@@ -38,23 +39,48 @@ const DISCONNECT_CODES = new Set([
38
39
  'ECONNRESET',
39
40
  'ERR_STREAM_DESTROYED',
40
41
  ]);
41
- /** The disconnect code this failure carries, if that is all it was. */
42
- function disconnectCode(value) {
43
- const code = value?.code;
44
- return 'string' === typeof code && DISCONNECT_CODES.has(code) ? code : undefined;
42
+ /**
43
+ * Nobody went away — nobody was ever there. A refused connect is the daemon not being up YET.
44
+ *
45
+ * This is the difference that made `runtime_crashed` useless. In the field **every crash event
46
+ * carried one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
47
+ * never-verified set.** The proxy is BUILT to tolerate this — it answers the handshake from cache
48
+ * and wakes a daemon on the next request — so reporting it as a crash meant the one metric that is
49
+ * supposed to say "Reticle broke" said nothing but "the daemon had not booted yet", and a real crash
50
+ * would have been invisible underneath it.
51
+ *
52
+ * Absorbed, not silenced: it still logs, and the outage it belongs to is already counted as
53
+ * `connect_error` on `mcp_connection_lost`, which is the metric that can actually act on it.
54
+ */
55
+ const UNREACHABLE_CODES = new Set(['ECONNREFUSED']);
56
+ /** The expected-failure code this value carries, and which class it belongs to. */
57
+ function expectedCode(value) {
58
+ const raw = value?.code;
59
+ if ('string' !== typeof raw)
60
+ return undefined;
61
+ if (DISCONNECT_CODES.has(raw))
62
+ return { code: raw, unreachable: false };
63
+ if (UNREACHABLE_CODES.has(raw))
64
+ return { code: raw, unreachable: true };
65
+ return undefined;
45
66
  }
46
67
  /**
47
- * Log an expected disconnect and report `true`, so the caller skips the crash report.
68
+ * Log an expected failure and report `true`, so the caller skips the crash report.
48
69
  *
49
70
  * It is still WRITTEN DOWN. A client leaving is normal, but a daemon that suddenly logs a hundred of
50
71
  * these is a finding — the difference between "not a crash" and "not worth knowing" is the whole
51
72
  * reason this logs instead of returning early in silence.
52
73
  */
53
- function absorbDisconnect(value, log, event) {
54
- const code = disconnectCode(value);
55
- if (code === undefined)
74
+ function absorbExpected(value, log, events) {
75
+ const hit = expectedCode(value);
76
+ if (hit === undefined)
56
77
  return false;
57
- log(event, { code, note: 'the client went away — expected, not a crash' });
78
+ log(hit.unreachable ? events.unreachable : events.disconnected, {
79
+ code: hit.code,
80
+ note: hit.unreachable
81
+ ? 'the daemon was not reachable — expected while it boots, not a crash'
82
+ : 'the client went away — expected, not a crash',
83
+ });
58
84
  return true;
59
85
  }
60
86
  /** The two ways a failure reaches the top of the process. Named so the analytics can tell them apart. */
@@ -62,6 +88,19 @@ export const CrashKind = {
62
88
  UNHANDLED_REJECTION: 'unhandled_rejection',
63
89
  UNCAUGHT_EXCEPTION: 'uncaught_exception',
64
90
  };
91
+ /**
92
+ * The ports Reticle itself uses, so a refusal can be classified as ours or somebody else's.
93
+ *
94
+ * Read here rather than threaded through `installCrashHandlers`: a crash handler is installed once,
95
+ * at startup, and the port can be set after it. The values are only ever compared against — the
96
+ * port a crash names is turned into an enum and then discarded.
97
+ */
98
+ function knownPorts() {
99
+ const configured = Number(process.env['RETICLE_PORT']);
100
+ return Number.isInteger(configured) && configured > 0
101
+ ? [RETICLE_DEFAULT_PORT, configured]
102
+ : [RETICLE_DEFAULT_PORT];
103
+ }
65
104
  /**
66
105
  * Report a crash with enough detail to actually diagnose it.
67
106
  *
@@ -85,6 +124,10 @@ function reportCrash(kind, value) {
85
124
  const message = describe(value);
86
125
  const { breadcrumb, inFlight } = getSessionMetrics().trail;
87
126
  const machine = machineSnapshot();
127
+ // Where the frames cannot say. A refused socket's stack is entirely node internals, so
128
+ // `reticleFrames` correctly keeps nothing and the report lands with no location at all — the
129
+ // single commonest crash shape in a loopback-heavy tool.
130
+ const cause = crashCause(value, knownPorts());
88
131
  void getTelemetry().emit(TelemetryEventKind.RUNTIME_CRASHED, {
89
132
  // A crash is ALWAYS reached through something the agent asked for — the daemon does nothing on
90
133
  // its own — so attributing it to the agent is accurate rather than a guess.
@@ -106,6 +149,9 @@ function reportCrash(kind, value) {
106
149
  arch: process.arch,
107
150
  // "Out of memory" and "our bug" look identical in a stack trace. This is what tells them apart.
108
151
  ...(machine !== undefined ? { machine } : {}),
152
+ // The syscall, the errno, whether it was loopback, whether the port was ours, and the
153
+ // innermost frame in NODE's own source. Present only when the error carries them.
154
+ ...cause,
109
155
  },
110
156
  });
111
157
  }
@@ -126,9 +172,45 @@ function reportCrash(kind, value) {
126
172
  * is the point: after this, silence in the log is itself the finding, because every other door is
127
173
  * now instrumented.
128
174
  */
175
+ /**
176
+ * Why the daemon is going away — carried ON the exit line, not left to be inferred.
177
+ *
178
+ * From the field (#123): a real gate log read `reticle_daemon_signalled SIGTERM` then
179
+ * `reticle_daemon_exiting code:0`, then 21 seconds with nothing listening. `code: 0` is the last
180
+ * line before the port goes dark and it cannot distinguish "shut down tidily" from "the bridge every
181
+ * app on this machine needs is now gone". A correct install was written up as a failure naming the
182
+ * fixture because of it.
183
+ *
184
+ * `UNKNOWN` is the load-bearing member, not a fallback: it means the process left through Node
185
+ * WITHOUT passing a shutdown path — an uncaught throw, a stray `process.exit`. That is a different
186
+ * fact from an idle exit, and the one worth noticing.
187
+ */
188
+ export const DaemonExitReason = {
189
+ /** A shutdown signal arrived (SIGTERM/SIGINT). */
190
+ SIGNAL: 'signal',
191
+ /** The idle timer fired and the daemon retired itself. */
192
+ IDLE: 'idle',
193
+ /** Left through Node, but not via any shutdown path we own. */
194
+ UNKNOWN: 'unknown',
195
+ };
196
+ /**
197
+ * Set by whichever shutdown path is running, read once by the exit handler.
198
+ *
199
+ * A module-level value rather than a parameter because the two are separated by the whole process
200
+ * lifetime: the reason is known when shutdown STARTS and the exit line is written when Node is on
201
+ * its way out. Exit runs once, on one thread, after everything else — so there is nothing to race.
202
+ */
203
+ let exitReason = DaemonExitReason.UNKNOWN;
204
+ /** Record why the daemon is shutting down. Called by a shutdown path before it exits. */
205
+ export function recordExitReason(reason) {
206
+ exitReason = reason;
207
+ }
129
208
  export function installExitTrace(proc, log) {
130
209
  proc.on('exit', (code) => {
131
- log('reticle_daemon_exiting', { code: 'number' === typeof code ? code : null });
210
+ log('reticle_daemon_exiting', {
211
+ code: 'number' === typeof code ? code : null,
212
+ reason: exitReason,
213
+ });
132
214
  });
133
215
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
134
216
  proc.on(signal, () => {
@@ -140,9 +222,12 @@ export function installExitTrace(proc, log) {
140
222
  }
141
223
  export function installDaemonResilience(proc, log, onFatal) {
142
224
  installExitTrace(proc, log);
143
- const disconnected = 'reticle_daemon_client_disconnected';
225
+ const expected = {
226
+ disconnected: 'reticle_daemon_client_disconnected',
227
+ unreachable: 'reticle_daemon_peer_unreachable',
228
+ };
144
229
  proc.on('unhandledRejection', (reason) => {
145
- if (absorbDisconnect(reason, log, disconnected))
230
+ if (absorbExpected(reason, log, expected))
146
231
  return;
147
232
  log('reticle_daemon_unhandled_rejection', { reason: describe(reason) });
148
233
  reportCrash(CrashKind.UNHANDLED_REJECTION, reason);
@@ -151,7 +236,7 @@ export function installDaemonResilience(proc, log, onFatal) {
151
236
  // Not fatal either: Node's "the process state is undefined" guidance is about a throw that
152
237
  // escaped everything, not about a write to a socket somebody closed. Exiting here would let one
153
238
  // departing client take down the daemon serving every other agent.
154
- if (absorbDisconnect(err, log, disconnected))
239
+ if (absorbExpected(err, log, expected))
155
240
  return;
156
241
  log('reticle_daemon_uncaught_exception', { error: describe(err) });
157
242
  reportCrash(CrashKind.UNCAUGHT_EXCEPTION, err);
@@ -171,16 +256,19 @@ export function installDaemonResilience(proc, log, onFatal) {
171
256
  * `onCrash` is for reporting only — it must not end the process.
172
257
  */
173
258
  export function installProxyResilience(proc, log, onCrash = reportCrash) {
174
- const disconnected = 'reticle_mcp_proxy_client_disconnected';
259
+ const expected = {
260
+ disconnected: 'reticle_mcp_proxy_client_disconnected',
261
+ unreachable: 'reticle_mcp_proxy_daemon_unreachable',
262
+ };
175
263
  proc.on('unhandledRejection', (reason) => {
176
- if (absorbDisconnect(reason, log, disconnected))
264
+ if (absorbExpected(reason, log, expected))
177
265
  return;
178
266
  log('reticle_mcp_proxy_unhandled_rejection', { reason: describe(reason) });
179
267
  onCrash(CrashKind.UNHANDLED_REJECTION, reason);
180
268
  });
181
269
  proc.on('uncaughtException', (err) => {
182
270
  // The client closing its end of stdio is how this process is SUPPOSED to end its day.
183
- if (absorbDisconnect(err, log, disconnected))
271
+ if (absorbExpected(err, log, expected))
184
272
  return;
185
273
  log('reticle_mcp_proxy_uncaught_exception', {
186
274
  error: describe(err),