@reticlehq/server 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/SKILL.md +27 -10
  2. package/dist/bridge/bridge.d.ts +12 -0
  3. package/dist/bridge/bridge.js +32 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/cli-doctor.d.ts +0 -5
  7. package/dist/cli/cli-doctor.js +71 -3
  8. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  9. package/dist/cli/cli-parse-feedback.js +56 -0
  10. package/dist/cli/cli-parse.d.ts +5 -9
  11. package/dist/cli/cli-parse.js +7 -23
  12. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  13. package/dist/cli/doctor-daemon-line.js +35 -0
  14. package/dist/cli/port-holder.d.ts +43 -0
  15. package/dist/cli/port-holder.js +99 -0
  16. package/dist/cli.js +131 -11
  17. package/dist/daemon/daemon-resilience.d.ts +24 -0
  18. package/dist/daemon/daemon-resilience.js +105 -17
  19. package/dist/daemon/daemon.js +26 -1
  20. package/dist/daemon/heartbeat.d.ts +115 -0
  21. package/dist/daemon/heartbeat.js +141 -0
  22. package/dist/daemon/idle-shutdown.d.ts +13 -5
  23. package/dist/daemon/idle-shutdown.js +14 -6
  24. package/dist/daemon/port-presence.d.ts +76 -0
  25. package/dist/daemon/port-presence.js +93 -0
  26. package/dist/daemon/state-dir.d.ts +7 -0
  27. package/dist/daemon/state-dir.js +59 -0
  28. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  29. package/dist/daemon/unclean-predecessor.js +27 -0
  30. package/dist/events/predicate-eval.d.ts +10 -0
  31. package/dist/events/predicate-eval.js +24 -4
  32. package/dist/events/predicate-parse.d.ts +23 -0
  33. package/dist/events/predicate-parse.js +54 -0
  34. package/dist/events/predicate.d.ts +5 -0
  35. package/dist/events/predicate.js +17 -3
  36. package/dist/events/ring-buffer.js +1 -16
  37. package/dist/flows/flow-replay.d.ts +1 -1
  38. package/dist/flows/flow-replay.js +19 -2
  39. package/dist/flows/flow-step-runners.js +2 -3
  40. package/dist/flows/flow-tools.js +10 -14
  41. package/dist/flows/role-drift-reason.d.ts +20 -0
  42. package/dist/flows/role-drift-reason.js +39 -0
  43. package/dist/flows/suite-flakes.d.ts +33 -0
  44. package/dist/flows/suite-flakes.js +32 -0
  45. package/dist/honesty/blind-spots.d.ts +19 -0
  46. package/dist/honesty/blind-spots.js +33 -0
  47. package/dist/honesty/verified.d.ts +10 -1
  48. package/dist/honesty/verified.js +16 -0
  49. package/dist/http-server.js +9 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +10 -1
  52. package/dist/init/agent-rules.d.ts +18 -0
  53. package/dist/init/agent-rules.js +22 -4
  54. package/dist/init/cra.d.ts +13 -0
  55. package/dist/init/cra.js +16 -0
  56. package/dist/init/detect.d.ts +6 -0
  57. package/dist/init/detect.js +8 -0
  58. package/dist/init/mcp-clients.d.ts +81 -0
  59. package/dist/init/mcp-clients.js +248 -0
  60. package/dist/init/node-io.js +8 -2
  61. package/dist/init/plan-framework.js +36 -2
  62. package/dist/init/plan.d.ts +14 -0
  63. package/dist/init/plan.js +73 -9
  64. package/dist/init/run.d.ts +18 -1
  65. package/dist/init/run.js +133 -16
  66. package/dist/init/windows-quote.d.ts +38 -0
  67. package/dist/init/windows-quote.js +70 -0
  68. package/dist/mcp/mcp-outage.d.ts +1 -0
  69. package/dist/mcp/mcp-outage.js +8 -1
  70. package/dist/mcp/mcp-proxy.d.ts +11 -1
  71. package/dist/mcp/mcp-proxy.js +30 -7
  72. package/dist/mcp/mcp.js +29 -2
  73. package/dist/pool/browser-pool.d.ts +9 -0
  74. package/dist/pool/browser-pool.js +38 -9
  75. package/dist/session/attachment-history.d.ts +46 -0
  76. package/dist/session/attachment-history.js +65 -0
  77. package/dist/session/no-session-diagnosis.d.ts +7 -0
  78. package/dist/session/no-session-diagnosis.js +53 -3
  79. package/dist/session/no-session-watch.d.ts +3 -1
  80. package/dist/session/no-session-watch.js +10 -2
  81. package/dist/session/pending-commands.js +1 -0
  82. package/dist/session/session-info.d.ts +17 -1
  83. package/dist/session/session-info.js +5 -1
  84. package/dist/session/session-manager.d.ts +3 -0
  85. package/dist/session/session-manager.js +72 -3
  86. package/dist/session/session.d.ts +1 -0
  87. package/dist/session/session.js +8 -0
  88. package/dist/telemetry/crash-cause.d.ts +47 -0
  89. package/dist/telemetry/crash-cause.js +104 -0
  90. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  91. package/dist/telemetry/daemon-telemetry.js +28 -13
  92. package/dist/telemetry/error-class.d.ts +26 -0
  93. package/dist/telemetry/error-class.js +52 -0
  94. package/dist/telemetry/feedback-cli.d.ts +7 -2
  95. package/dist/telemetry/feedback-cli.js +49 -5
  96. package/dist/telemetry/feedback-context.d.ts +1 -6
  97. package/dist/telemetry/feedback-context.js +64 -4
  98. package/dist/telemetry/git-facts.d.ts +7 -0
  99. package/dist/telemetry/git-facts.js +4 -4
  100. package/dist/telemetry/project-profile.js +2 -1
  101. package/dist/telemetry/session-metrics.d.ts +45 -3
  102. package/dist/telemetry/session-metrics.js +247 -9
  103. package/dist/telemetry/telemetry.d.ts +19 -1
  104. package/dist/telemetry/telemetry.js +31 -8
  105. package/dist/tools/act-tools.js +33 -6
  106. package/dist/tools/assert-verdict.d.ts +7 -1
  107. package/dist/tools/assert-verdict.js +15 -3
  108. package/dist/tools/browser-tools.js +8 -2
  109. package/dist/tools/contract-tools.d.ts +0 -5
  110. package/dist/tools/contract-tools.js +26 -0
  111. package/dist/tools/dynamic-tools.js +6 -1
  112. package/dist/tools/error-recovery.d.ts +14 -0
  113. package/dist/tools/error-recovery.js +83 -7
  114. package/dist/tools/feedback-invite.d.ts +48 -0
  115. package/dist/tools/feedback-invite.js +71 -0
  116. package/dist/tools/invoke-tool.d.ts +0 -6
  117. package/dist/tools/invoke-tool.js +69 -1
  118. package/dist/tools/navigate-arrival.d.ts +26 -0
  119. package/dist/tools/navigate-arrival.js +60 -0
  120. package/dist/tools/navigate-result.d.ts +15 -1
  121. package/dist/tools/navigate-result.js +17 -10
  122. package/dist/tools/observe-tools.js +22 -4
  123. package/dist/tools/real-input-attempt.js +10 -5
  124. package/dist/tools/tool-kit.d.ts +8 -0
  125. package/dist/tools/tool-surface.js +13 -0
  126. package/docs/README.md +55 -0
  127. package/docs/agent-cheatsheet.md +2 -2
  128. package/docs/benchmarks.md +1 -1
  129. package/docs/fixtures-dispatch-receiver.yml +76 -0
  130. package/docs/fixtures.md +17 -0
  131. package/docs/gate-plan.md +98 -0
  132. package/docs/gates.md +117 -0
  133. package/docs/local-registry.md +1 -1
  134. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  135. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  136. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  137. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  138. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  139. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  140. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  141. package/docs/matrix/MATRIX.md +26 -0
  142. package/docs/matrix/README.md +59 -0
  143. package/docs/mint.json +1 -1
  144. package/docs/system-map.md +152 -0
  145. package/docs/telemetry-contract.md +2 -2
  146. package/docs/telemetry.md +7 -0
  147. package/package.json +2 -2
@@ -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,47 @@ 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
+ async function waitForPresence(port, want, timeoutMs) {
136
+ const deadline = Date.now() + timeoutMs;
137
+ for (;;) {
138
+ const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
139
+ if (presence === want)
140
+ return true;
141
+ if (Date.now() >= deadline)
142
+ return false;
143
+ await new Promise((resolve) => setTimeout(resolve, PRESENCE_POLL_MS));
144
+ }
145
+ }
74
146
  function handleStop(port, quiet) {
75
147
  const pid = readPid(port);
76
148
  if (null === pid || !isAlive(pid)) {
@@ -100,7 +172,16 @@ function handleStop(port, quiet) {
100
172
  function handleStatus(port) {
101
173
  const pid = readPid(port);
102
174
  if (null === pid || !isAlive(pid)) {
103
- log('reticle_status', { port, running: false });
175
+ // `running: false` on its own has been reported about a port that was demonstrably occupied,
176
+ // because the pid file is not the port. Ask the port before answering.
177
+ void probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then((presence) => {
178
+ log('reticle_status', {
179
+ port,
180
+ running: presenceIsUsable(presence),
181
+ presence,
182
+ ...(presence === PortPresence.FOREIGN ? { reason: describePresence(presence, port) } : {}),
183
+ });
184
+ });
104
185
  return;
105
186
  }
106
187
  // The daemon is up — ask it for live sessions + health so status is at-a-glance, not just a pid.
@@ -327,12 +408,20 @@ function handleDaemonInner(parsed) {
327
408
  removePid(parsed.port);
328
409
  process.exit(1);
329
410
  });
330
- const shutdown = () => {
411
+ const shutdown = (reason) => {
412
+ // Recorded BEFORE the async chain: `installExitTrace` reads it when Node is on its way out,
413
+ // which is after everything below has run. Without it the exit line says `code: 0` and a
414
+ // reader cannot tell a tidy stop from the bridge disappearing — see #123.
415
+ recordExitReason(reason);
331
416
  // Awaited before the close/exit chain: `process.exit(0)` kills an in-flight POST, and this is
332
417
  // the one event carrying the whole session. A failed send resolves anyway (emit swallows its
333
418
  // own errors), so this can delay the exit by at most the send timeout, never prevent it.
334
419
  void daemonTelemetry
335
- .shutdown()
420
+ // The reason rides out on the session summary, which is the only event that fires at this
421
+ // exact moment. The proxy emits the matching `mcp_connection_lost` and cannot know it —
422
+ // it sees a socket end and nothing more, which is how a scheduled idle exit came to make up
423
+ // 299 of 321 "outages". See SessionSummary.exit.
424
+ .shutdown(reason)
336
425
  .then(() => server.close())
337
426
  .then(() => {
338
427
  removePid(parsed.port);
@@ -345,8 +434,10 @@ function handleDaemonInner(parsed) {
345
434
  process.exit(1);
346
435
  });
347
436
  };
348
- process.on('SIGTERM', shutdown);
349
- process.on('SIGINT', shutdown);
437
+ // Wrapped rather than passed directly: a signal handler receives the signal name as its first
438
+ // argument, which would otherwise arrive as the reason.
439
+ process.on('SIGTERM', () => shutdown(DaemonExitReason.SIGNAL));
440
+ process.on('SIGINT', () => shutdown(DaemonExitReason.SIGNAL));
350
441
  // Self-shut-down when idle so a detached daemon (and any headless Chromium it launched) never
351
442
  // lingers on the user's machine after the editor closes. Reuses the same clean shutdown path.
352
443
  const attachedGraceEnv = process.env[ReticleEnv.IDLE_ATTACHED];
@@ -363,10 +454,23 @@ function handleDaemonInner(parsed) {
363
454
  : { attachedGraceMs: resolveIdleShutdownMs(attachedGraceEnv) }),
364
455
  onShutdown: () => {
365
456
  log('reticle_daemon_idle_exit', { port: parsed.port });
366
- shutdown();
457
+ shutdown(DaemonExitReason.IDLE);
367
458
  },
368
459
  });
369
460
  idleShutdown.start();
461
+ // Say so, regularly, so a GAP in this log is itself evidence. `installExitTrace` hooks
462
+ // `'exit'`, which a SIGKILL never fires — so a killed daemon left nothing behind, and the one
463
+ // that exited tidily logged `code: 0`. A reader could not tell "shut down cleanly" from "the
464
+ // bridge every app on this machine needs is gone", and a correct SvelteKit install was written
465
+ // up as an install failure on exactly that ambiguity. See daemon/heartbeat.ts.
466
+ new DaemonHeartbeat({
467
+ log,
468
+ intervalMs: resolveHeartbeatMs(process.env[ReticleEnv.HEARTBEAT]),
469
+ facts: () => ({
470
+ sessions: server.bridge.sessions.count(),
471
+ served: everServedToolCall(),
472
+ }),
473
+ }).start();
370
474
  })
371
475
  .catch((error) => {
372
476
  const message = error instanceof Error ? error.message : String(error);
@@ -447,7 +551,23 @@ function handleMcp(opts) {
447
551
  })
448
552
  .finally(() => {
449
553
  // Also `void`: the proxy runs for the life of the process and is never awaited by anyone.
450
- void startMcpProxy(port, ensure);
554
+ //
555
+ // The `.catch` is load-bearing despite that. `startMcpProxy` rejects when its FIRST connect
556
+ // fails — which is precisely the case this block exists to tolerate, a daemon that is not
557
+ // there yet — and with nothing attached that reject became an unhandledRejection. Reported
558
+ // from a win32 user as a crash reading `connect ECONNREFUSED` with an EMPTY frame list,
559
+ // because the stack of a refused socket is entirely node internals and the privacy filter
560
+ // keeps only Reticle frames. So the one path we most want diagnosable arrived as an anonymous
561
+ // crash. The proxy itself is unaffected — it has already installed its stdin reader and goes
562
+ // on serving from cache, waking a daemon on the next request — which is exactly why this
563
+ // must be logged as the expected condition it is rather than reported as a defect.
564
+ void startMcpProxy(port, ensure).catch((err) => {
565
+ log('reticle_mcp_proxy_first_connect_failed', {
566
+ port,
567
+ error: err instanceof Error ? err.message : String(err),
568
+ note: 'serving from cache; the next client request will try to start a daemon',
569
+ });
570
+ });
451
571
  });
452
572
  }
453
573
  function handleLegacyDrive(parsed) {
@@ -528,7 +648,7 @@ function main() {
528
648
  handleTelemetry(parsed.action);
529
649
  break;
530
650
  case 'feedback':
531
- void handleFeedback(parsed.text, parsed.rating, parsed.bug);
651
+ void handleFeedback(parsed.text, parsed.rating, parsed.bug, parsed.feedbackKind, parsed.agent);
532
652
  break;
533
653
  case 'identify':
534
654
  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. Measured 2026-08-10/11: **84 of 84
46
+ * crash events were 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),
@@ -1,9 +1,10 @@
1
1
  import { join } from 'node:path';
2
2
  import { homedir } from 'node:os';
3
- import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync, readdirSync, statSync, renameSync, } from 'node:fs';
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync, readdirSync, statSync, renameSync, writeSync, } from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { daemonRegistryFileName, ReticleEnv } from '@reticlehq/core';
6
6
  import { log } from '../log.js';
7
+ import { uncleanPredecessor } from './unclean-predecessor.js';
7
8
  /** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
8
9
  export const STATE_DIR_ENV = ReticleEnv.STATE_DIR;
9
10
  /**
@@ -233,6 +234,8 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
233
234
  // O_EXCL spawn-lock: only the FIRST racer to create the pidfile spawns. A concurrent second gets
234
235
  // EEXIST — if a LIVE daemon owns the port it skips (no duplicate detached daemon, no clobbered pid);
235
236
  // a stale pidfile from a crashed daemon is reclaimed. Returns false when it did not spawn.
237
+ /** Set when this start reclaimed a pidfile whose owner is gone — see unclean-predecessor.ts. */
238
+ let orphanPid = null;
236
239
  let lockFd;
237
240
  try {
238
241
  lockFd = deps.openFile(pidFilePath, 'wx');
@@ -241,6 +244,10 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
241
244
  const existing = readPid(port, deps.home);
242
245
  if (existing !== null && deps.pidAlive(existing))
243
246
  return false;
247
+ // This branch IS the crash detection — its own comment has always said so ("a stale pidfile from
248
+ // a crashed daemon is reclaimed"), it just never said it out loud. Captured before the unlink,
249
+ // because after it there is no trace left that the previous daemon died at all.
250
+ orphanPid = uncleanPredecessor(existing, false, process.pid);
244
251
  try {
245
252
  unlinkSync(pidFilePath);
246
253
  lockFd = deps.openFile(pidFilePath, 'wx');
@@ -267,6 +274,24 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
267
274
  }
268
275
  return false;
269
276
  }
277
+ // Into the daemon log itself, not the parent's stdout: `doctor` points the reader at this file, and
278
+ // a death recorded anywhere else is a death they will not find. Written through the fd the child is
279
+ // about to inherit, so it lands immediately before that child's first line — exactly where a reader
280
+ // comparing two `mcp_daemon_started` entries is already looking.
281
+ if (null !== orphanPid) {
282
+ try {
283
+ writeSync(logFd, `${JSON.stringify({
284
+ // Same convention as log(): the clock is read at the I/O boundary, not injected.
285
+ t: new Date().toISOString(),
286
+ event: 'reticle_daemon_previous_died_unclean',
287
+ port,
288
+ pid: orphanPid,
289
+ })}\n`);
290
+ }
291
+ catch {
292
+ // A log we cannot write is not a reason to refuse to start the daemon.
293
+ }
294
+ }
270
295
  let child;
271
296
  try {
272
297
  child = deps.spawnChild(nodeExec, [scriptPath, ...args], {