@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
@@ -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], {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A regular beat, so SILENCE in the daemon log becomes evidence.
3
+ *
4
+ * `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
5
+ * crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
6
+ * thread — leaves no trace at all, and the last line before its port goes dark is whatever it
7
+ * happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
8
+ * violent death are the same shape to anyone reading the file.
9
+ *
10
+ * Measured cost of that: a correct SvelteKit install was written up as an install failure, because
11
+ * the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
12
+ * report; the daemon was not.
13
+ *
14
+ * Two halves, and the second is what makes the first worth having:
15
+ * - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
16
+ * - classifyDaemonLife reads a log back and says which way the daemon ended.
17
+ *
18
+ * A heartbeat nobody interprets is just log volume.
19
+ */
20
+ /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
21
+ export declare const DAEMON_HEARTBEAT_EVENT = "reticle_daemon_alive";
22
+ /** Written by installExitTrace when the process exits through Node. */
23
+ export declare const DAEMON_EXIT_EVENT = "reticle_daemon_exiting";
24
+ /** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
25
+ export declare const DAEMON_SIGNAL_EVENT = "reticle_daemon_signalled";
26
+ /**
27
+ * How often a live daemon says so.
28
+ *
29
+ * The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
30
+ * that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
31
+ * to be legible against a connect window, which is measured in seconds.
32
+ */
33
+ export declare const DAEMON_HEARTBEAT_MS = 30000;
34
+ /**
35
+ * The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
36
+ * rather than throwing, and 0 or negative is not a legal cadence — it would spin.
37
+ */
38
+ export declare function resolveHeartbeatMs(raw: string | undefined): number;
39
+ interface HeartbeatFacts {
40
+ /** Browser sessions connected right now. */
41
+ sessions: number;
42
+ /**
43
+ * Has this daemon EVER served a tool call? A lifetime fact, not a window count.
44
+ *
45
+ * It is the difference between a death that cost somebody work and one that cost nothing — the
46
+ * same distinction `isUselessDaemon` already draws. A windowed count would reset under the reader
47
+ * and make a long-lived daemon look freshly idle.
48
+ */
49
+ served: boolean;
50
+ }
51
+ interface DaemonHeartbeatOptions {
52
+ log: (event: string, fields: Record<string, unknown>) => void;
53
+ /** Read at each beat, never cached — the point is to describe the daemon NOW. */
54
+ facts: () => HeartbeatFacts;
55
+ intervalMs?: number;
56
+ /** Injected, per the repo rule: never call Date.now() inside logic. */
57
+ clock?: () => number;
58
+ }
59
+ export declare class DaemonHeartbeat {
60
+ #private;
61
+ constructor(opts: DaemonHeartbeatOptions);
62
+ /**
63
+ * One beat. Exposed so a test can drive it with an injected clock rather than waiting.
64
+ *
65
+ * Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
66
+ * it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
67
+ * ambiguity this exists to remove.
68
+ */
69
+ beat(): void;
70
+ start(): void;
71
+ stop(): void;
72
+ }
73
+ /** How a daemon's life ended, as far as its own log can say. */
74
+ export declare const DaemonEnd: {
75
+ /** Still beating within tolerance. */
76
+ readonly ALIVE: "alive";
77
+ /** Logged its own exit, with no signal before it. */
78
+ readonly CLEAN: "clean";
79
+ /** A signal arrived — the cause an exit line with `code: 0` does not carry. */
80
+ readonly SIGNALLED: "signalled";
81
+ /** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
82
+ readonly DIED_SILENTLY: "died_silently";
83
+ /** No beat was ever emitted, so this log cannot answer the question. */
84
+ readonly UNKNOWN: "unknown";
85
+ };
86
+ export type DaemonEnd = (typeof DaemonEnd)[keyof typeof DaemonEnd];
87
+ /**
88
+ * One parsed line of a daemon log. The index signature is not laziness: these lines carry whatever
89
+ * their event needed (`code`, `port`, `pid`, `reason`, …), and a reader that refused unknown keys
90
+ * would reject the very file it exists to read.
91
+ */
92
+ interface LogLine {
93
+ t: number;
94
+ event: string;
95
+ signal?: unknown;
96
+ everyMs?: unknown;
97
+ [field: string]: unknown;
98
+ }
99
+ export interface DaemonLife {
100
+ end: DaemonEnd;
101
+ /** When the last beat landed, or undefined if there was never one. */
102
+ lastBeatMs?: number;
103
+ /** How long the log has been silent, when that is the finding. */
104
+ silentForMs?: number;
105
+ signal?: string;
106
+ }
107
+ /**
108
+ * Read a daemon log back and say how it ended.
109
+ *
110
+ * UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
111
+ * existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
112
+ * signal we never sent is the same confident-wrong this module replaces.
113
+ */
114
+ export declare function classifyDaemonLife(lines: readonly LogLine[], nowMs: number): DaemonLife;
115
+ export {};
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A regular beat, so SILENCE in the daemon log becomes evidence.
3
+ *
4
+ * `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
5
+ * crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
6
+ * thread — leaves no trace at all, and the last line before its port goes dark is whatever it
7
+ * happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
8
+ * violent death are the same shape to anyone reading the file.
9
+ *
10
+ * Measured cost of that: a correct SvelteKit install was written up as an install failure, because
11
+ * the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
12
+ * report; the daemon was not.
13
+ *
14
+ * Two halves, and the second is what makes the first worth having:
15
+ * - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
16
+ * - classifyDaemonLife reads a log back and says which way the daemon ended.
17
+ *
18
+ * A heartbeat nobody interprets is just log volume.
19
+ */
20
+ /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
21
+ export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
22
+ /** Written by installExitTrace when the process exits through Node. */
23
+ export const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
24
+ /** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
25
+ export const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
26
+ /**
27
+ * How often a live daemon says so.
28
+ *
29
+ * The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
30
+ * that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
31
+ * to be legible against a connect window, which is measured in seconds.
32
+ */
33
+ export const DAEMON_HEARTBEAT_MS = 30_000;
34
+ /**
35
+ * How many beats may be missed before a reader calls it dead.
36
+ *
37
+ * Timers slip under load — the battery boots three servers and a browser — and a reader that cries
38
+ * death on one late beat is a reader people learn to ignore. Two intervals plus a margin.
39
+ */
40
+ const MISSED_BEATS_TOLERATED = 2;
41
+ /**
42
+ * The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
43
+ * rather than throwing, and 0 or negative is not a legal cadence — it would spin.
44
+ */
45
+ export function resolveHeartbeatMs(raw) {
46
+ if (raw === undefined || '' === raw.trim())
47
+ return DAEMON_HEARTBEAT_MS;
48
+ const n = Number(raw);
49
+ if (!Number.isFinite(n) || n <= 0)
50
+ return DAEMON_HEARTBEAT_MS;
51
+ return Math.floor(n);
52
+ }
53
+ export class DaemonHeartbeat {
54
+ #log;
55
+ #facts;
56
+ #intervalMs;
57
+ #clock;
58
+ #startedAt;
59
+ #timer;
60
+ constructor(opts) {
61
+ this.#log = opts.log;
62
+ this.#facts = opts.facts;
63
+ this.#intervalMs = opts.intervalMs ?? DAEMON_HEARTBEAT_MS;
64
+ this.#clock = opts.clock ?? (() => Date.now());
65
+ this.#startedAt = this.#clock();
66
+ }
67
+ /**
68
+ * One beat. Exposed so a test can drive it with an injected clock rather than waiting.
69
+ *
70
+ * Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
71
+ * it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
72
+ * ambiguity this exists to remove.
73
+ */
74
+ beat() {
75
+ const facts = this.#facts();
76
+ this.#log(DAEMON_HEARTBEAT_EVENT, {
77
+ uptimeMs: this.#clock() - this.#startedAt,
78
+ // The cadence travels WITH the beat so a reader never hard-codes it. Hard-coding is how the
79
+ // reader and the emitter drift apart the first time the interval is tuned.
80
+ everyMs: this.#intervalMs,
81
+ sessions: facts.sessions,
82
+ served: facts.served,
83
+ });
84
+ }
85
+ start() {
86
+ if (this.#timer !== undefined)
87
+ return;
88
+ this.#timer = setInterval(() => this.beat(), this.#intervalMs);
89
+ // Never the reason a process stays alive — this is a diagnostic, not a workload.
90
+ this.#timer.unref();
91
+ }
92
+ stop() {
93
+ if (this.#timer === undefined)
94
+ return;
95
+ clearInterval(this.#timer);
96
+ this.#timer = undefined;
97
+ }
98
+ }
99
+ /** How a daemon's life ended, as far as its own log can say. */
100
+ export const DaemonEnd = {
101
+ /** Still beating within tolerance. */
102
+ ALIVE: 'alive',
103
+ /** Logged its own exit, with no signal before it. */
104
+ CLEAN: 'clean',
105
+ /** A signal arrived — the cause an exit line with `code: 0` does not carry. */
106
+ SIGNALLED: 'signalled',
107
+ /** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
108
+ DIED_SILENTLY: 'died_silently',
109
+ /** No beat was ever emitted, so this log cannot answer the question. */
110
+ UNKNOWN: 'unknown',
111
+ };
112
+ /**
113
+ * Read a daemon log back and say how it ended.
114
+ *
115
+ * UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
116
+ * existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
117
+ * signal we never sent is the same confident-wrong this module replaces.
118
+ */
119
+ export function classifyDaemonLife(lines, nowMs) {
120
+ const beats = lines.filter((l) => l.event === DAEMON_HEARTBEAT_EVENT);
121
+ const exit = lines.findLast((l) => l.event === DAEMON_EXIT_EVENT);
122
+ const signal = lines.findLast((l) => l.event === DAEMON_SIGNAL_EVENT);
123
+ if (exit !== undefined) {
124
+ // A signal is the CAUSE; the exit line only says it happened. Reporting `clean` for a SIGTERMed
125
+ // daemon is how "shut down tidily" and "the bridge every app needs is gone" became one sentence.
126
+ return signal === undefined
127
+ ? { end: DaemonEnd.CLEAN }
128
+ : {
129
+ end: DaemonEnd.SIGNALLED,
130
+ ...('string' === typeof signal.signal ? { signal: signal.signal } : {}),
131
+ };
132
+ }
133
+ const last = beats.at(-1);
134
+ if (last === undefined)
135
+ return { end: DaemonEnd.UNKNOWN };
136
+ const everyMs = 'number' === typeof last.everyMs ? last.everyMs : DAEMON_HEARTBEAT_MS;
137
+ const silentForMs = nowMs - last.t;
138
+ return silentForMs > everyMs * MISSED_BEATS_TOLERATED
139
+ ? { end: DaemonEnd.DIED_SILENTLY, lastBeatMs: last.t, silentForMs }
140
+ : { end: DaemonEnd.ALIVE, lastBeatMs: last.t };
141
+ }
@@ -35,12 +35,20 @@ export declare class IdleShutdown {
35
35
  stop(): void;
36
36
  }
37
37
  /**
38
- * Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of milliseconds,
39
- * `0` to disable. Anything missing/invalid falls back to the default. Pure.
38
+ * The idle re-check cadence, from the environment. Same shape as the grace resolver, with one
39
+ * deliberate difference: `0` is not a legal cadence here (it would spin), so `<= 0` falls back to
40
+ * the default rather than meaning anything.
40
41
  */
42
+ export declare function resolveIdleCheckMs(raw: string | undefined): number;
41
43
  /**
42
- * The idle re-check cadence, from the environment. Same shape as the grace resolver: a bad value
43
- * falls back rather than throwing, and 0/negative is not a legal cadence (it would spin).
44
+ * Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of
45
+ * milliseconds, `0` to disable. Anything missing or invalid falls back to the default. Pure.
46
+ *
47
+ * The disable is NOT implemented here — 0 is passed through as 0, and `IdleShutdown` treats
48
+ * `graceMs <= 0` as "never self-shut-down" in both `start()` and `check()`. Said out loud because
49
+ * this docblock had drifted onto the function above it, leaving this one undocumented next to a
50
+ * bare `Math.floor`: a reviewer driving 2.5.0 read it as a documented option with no branch
51
+ * implementing it and filed the gate that depends on it as inverted. The behaviour was correct in
52
+ * every build; only the code was readable as broken.
44
53
  */
45
- export declare function resolveIdleCheckMs(raw: string | undefined): number;
46
54
  export declare function resolveIdleShutdownMs(raw: string | undefined): number;
@@ -65,12 +65,9 @@ export class IdleShutdown {
65
65
  }
66
66
  }
67
67
  /**
68
- * Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of milliseconds,
69
- * `0` to disable. Anything missing/invalid falls back to the default. Pure.
70
- */
71
- /**
72
- * The idle re-check cadence, from the environment. Same shape as the grace resolver: a bad value
73
- * falls back rather than throwing, and 0/negative is not a legal cadence (it would spin).
68
+ * The idle re-check cadence, from the environment. Same shape as the grace resolver, with one
69
+ * deliberate difference: `0` is not a legal cadence here (it would spin), so `<= 0` falls back to
70
+ * the default rather than meaning anything.
74
71
  */
75
72
  export function resolveIdleCheckMs(raw) {
76
73
  if (raw === undefined || '' === raw.trim())
@@ -80,6 +77,17 @@ export function resolveIdleCheckMs(raw) {
80
77
  return SESSION_LIFECYCLE.DAEMON_IDLE_CHECK_MS;
81
78
  return Math.floor(n);
82
79
  }
80
+ /**
81
+ * Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of
82
+ * milliseconds, `0` to disable. Anything missing or invalid falls back to the default. Pure.
83
+ *
84
+ * The disable is NOT implemented here — 0 is passed through as 0, and `IdleShutdown` treats
85
+ * `graceMs <= 0` as "never self-shut-down" in both `start()` and `check()`. Said out loud because
86
+ * this docblock had drifted onto the function above it, leaving this one undocumented next to a
87
+ * bare `Math.floor`: a reviewer driving 2.5.0 read it as a documented option with no branch
88
+ * implementing it and filed the gate that depends on it as inverted. The behaviour was correct in
89
+ * every build; only the code was readable as broken.
90
+ */
83
91
  export function resolveIdleShutdownMs(raw) {
84
92
  if (raw === undefined || '' === raw.trim())
85
93
  return SESSION_LIFECYCLE.DAEMON_IDLE_SHUTDOWN_MS;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
3
+ *
4
+ * The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
5
+ * `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
6
+ * default bridge URL says `localhost`. So on the platform that is most of our installs, and the
7
+ * browser that is most of that, the documented default cannot connect — and the error text names the
8
+ * daemon, containers and WSL, none of which is the cause.
9
+ *
10
+ * This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
11
+ * WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
12
+ * second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
13
+ * only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
14
+ *
15
+ * Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
16
+ * leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
17
+ * precondition for serving.
18
+ */
19
+ /** IPv6 loopback. The other half of what `localhost` can mean. */
20
+ export declare const IPV6_LOOPBACK = "::1";
21
+ export interface LoopbackAlias {
22
+ /** False when the alias could not be bound — the daemon still serves IPv4 as before. */
23
+ opened: boolean;
24
+ close: () => Promise<void>;
25
+ }
26
+ /**
27
+ * Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
28
+ *
29
+ * Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
30
+ * trust holds — the alias does not widen who may connect, only which address they may name.
31
+ */
32
+ export declare function openLoopbackAlias(port: number): Promise<LoopbackAlias>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
3
+ *
4
+ * The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
5
+ * `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
6
+ * default bridge URL says `localhost`. So on the platform that is most of our installs, and the
7
+ * browser that is most of that, the documented default cannot connect — and the error text names the
8
+ * daemon, containers and WSL, none of which is the cause.
9
+ *
10
+ * This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
11
+ * WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
12
+ * second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
13
+ * only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
14
+ *
15
+ * Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
16
+ * leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
17
+ * precondition for serving.
18
+ */
19
+ import net from 'node:net';
20
+ import { LOOPBACK_HOST } from '@reticlehq/core';
21
+ /** IPv6 loopback. The other half of what `localhost` can mean. */
22
+ export const IPV6_LOOPBACK = '::1';
23
+ /**
24
+ * Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
25
+ *
26
+ * Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
27
+ * trust holds — the alias does not widen who may connect, only which address they may name.
28
+ */
29
+ export function openLoopbackAlias(port) {
30
+ /** Every socket this alias has open, so shutdown can end them instead of waiting them out. */
31
+ const live = new Set();
32
+ const server = net.createServer((inbound) => {
33
+ const outbound = net.connect(port, LOOPBACK_HOST);
34
+ live.add(inbound).add(outbound);
35
+ const drop = (socket) => {
36
+ live.delete(socket);
37
+ };
38
+ inbound.on('close', () => drop(inbound));
39
+ outbound.on('close', () => drop(outbound));
40
+ // A dead peer on either side must tear down the pair, not surface as an unhandled 'error'
41
+ // event — which in Node terminates the daemon this alias exists to make reachable.
42
+ inbound.on('error', () => outbound.destroy());
43
+ outbound.on('error', () => inbound.destroy());
44
+ inbound.pipe(outbound);
45
+ outbound.pipe(inbound);
46
+ });
47
+ return new Promise((resolve) => {
48
+ /**
49
+ * Stop listening and END EVERYTHING, rather than draining.
50
+ *
51
+ * `server.close()` fires its callback only once every existing connection has gone, so a single
52
+ * held-open socket — a keep-alive, an SSE stream, exactly what this daemon serves — means the
53
+ * callback never fires. The daemon's shutdown chain awaits this, so an un-drained alias silently
54
+ * became a daemon that ignores SIGTERM: `reticle stop` timed out, the process stayed alive, and
55
+ * the port stayed held. Caught by the heartbeat spec, which is the one gate that watches a
56
+ * daemon actually die.
57
+ *
58
+ * A best-effort side listener must never be able to hold up the shutdown of the thing it exists
59
+ * to make reachable. We are exiting; ending these sockets is the correct outcome, not a
60
+ * compromise.
61
+ */
62
+ const close = () => new Promise((done) => {
63
+ server.close(() => done());
64
+ for (const socket of live)
65
+ socket.destroy();
66
+ live.clear();
67
+ });
68
+ server.once('error', () => {
69
+ // Nothing was bound, so there is nothing to close — but the caller still gets a close it can
70
+ // call unconditionally, rather than a shape it has to branch on.
71
+ resolve({ opened: false, close: () => Promise.resolve() });
72
+ });
73
+ server.listen(port, IPV6_LOOPBACK, () => {
74
+ // Never the reason the process stays up: if the daemon is otherwise done, this must not be
75
+ // what keeps the event loop alive.
76
+ server.unref();
77
+ resolve({ opened: true, close });
78
+ });
79
+ });
80
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What is actually on the bridge port — three states, because two produced a lie.
3
+ *
4
+ * `isRunning` reads the pid file and never the port. So "a stranger holds 4400" and "nothing holds
5
+ * 4400" both answer `running: false`, and every surface built on it inherits that:
6
+ *
7
+ * $ reticle serve
8
+ * {"event":"reticle_daemon_spawned","port":4400} <- exit 0
9
+ * $ reticle status
10
+ * {"event":"reticle_status","port":4400,"running":false}
11
+ * $ tail -1 ~/.reticle/daemon-4400.log
12
+ * {"event":"reticle_daemon_start_failed","error":"listen EADDRINUSE: ... 127.0.0.1:4400"}
13
+ *
14
+ * The daemon knew exactly what was wrong and wrote it where nobody looks. The true sentence — *port
15
+ * 4400 is held by another process* — was never said by anything a person sees.
16
+ *
17
+ * Deliberately probe-based rather than lsof-based: two questions (does TCP connect, does `/status`
18
+ * answer) separate all three states with no platform-specific code, and they work on Windows, where
19
+ * every `lsof` recipe in this repo does not. Naming the holding process is a nicer message and a
20
+ * strictly harder problem; it is not needed to stop lying.
21
+ */
22
+ export declare const PortPresence: {
23
+ /** A Reticle daemon answered `/status`. The only usable state. */
24
+ readonly DAEMON: "daemon";
25
+ /**
26
+ * Something accepts connections here and does not serve Reticle's status endpoint.
27
+ *
28
+ * A foreign process, or one of our own daemons wedged mid-start — and those are the same fact for
29
+ * every caller: this port cannot be bound and cannot be used.
30
+ */
31
+ readonly FOREIGN: "foreign";
32
+ /** Nothing is listening. */
33
+ readonly FREE: "free";
34
+ };
35
+ export type PortPresence = (typeof PortPresence)[keyof typeof PortPresence];
36
+ interface PortFacts {
37
+ /** A TCP connection to the port was accepted. */
38
+ tcpOpen: boolean;
39
+ /** An HTTP GET of `/status` returned a Reticle status payload. */
40
+ statusAnswered: boolean;
41
+ }
42
+ /**
43
+ * Pure. The probes live at the call sites (`probeDaemon`, `fetchStatus`) so this stays testable
44
+ * without a socket, and so a probe that starts lying is a separate failure from a rule that does.
45
+ */
46
+ export declare function classifyPort(facts: PortFacts): PortPresence;
47
+ /** True only for a daemon that answered. Anything else must not be treated as "running". */
48
+ export declare function presenceIsUsable(presence: PortPresence): boolean;
49
+ /**
50
+ * Ask the port both questions and classify the answers.
51
+ *
52
+ * The TCP probe is first and cheap; `/status` is only asked when something accepted, which keeps the
53
+ * common "nothing there" case to one failed connect. Impure by necessity — the rule above is where
54
+ * the behaviour lives, and it is tested without a socket.
55
+ */
56
+ export declare function probePresence(port: number, probes: {
57
+ tcpOpen: (port: number) => Promise<boolean>;
58
+ status: (port: number) => Promise<unknown>;
59
+ }): Promise<PortPresence>;
60
+ /**
61
+ * Who is holding the port, when we can tell.
62
+ *
63
+ * `FOREIGN` covers two situations that need the same answer to "can I bind this?" and OPPOSITE
64
+ * answers to "what do I do?": a stranger's process, and one of our own daemons wedged. Passing
65
+ * these lets the sentence tell them apart when — and only when — the recorded pid for the port
66
+ * matches the process actually holding it.
67
+ */
68
+ export interface PortHolder {
69
+ /** The pid we recorded for this port, from the daemon pid file. `null` when there is none. */
70
+ ourPid: number | null;
71
+ /** The pid actually holding the port, when the caller could determine it. */
72
+ holderPid: number | undefined;
73
+ }
74
+ /** One sentence a human can act on, for each state. Exhaustive by switch, so a new state must add one. */
75
+ export declare function describePresence(presence: PortPresence, port: number, holder?: PortHolder): string;
76
+ export {};
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What is actually on the bridge port — three states, because two produced a lie.
3
+ *
4
+ * `isRunning` reads the pid file and never the port. So "a stranger holds 4400" and "nothing holds
5
+ * 4400" both answer `running: false`, and every surface built on it inherits that:
6
+ *
7
+ * $ reticle serve
8
+ * {"event":"reticle_daemon_spawned","port":4400} <- exit 0
9
+ * $ reticle status
10
+ * {"event":"reticle_status","port":4400,"running":false}
11
+ * $ tail -1 ~/.reticle/daemon-4400.log
12
+ * {"event":"reticle_daemon_start_failed","error":"listen EADDRINUSE: ... 127.0.0.1:4400"}
13
+ *
14
+ * The daemon knew exactly what was wrong and wrote it where nobody looks. The true sentence — *port
15
+ * 4400 is held by another process* — was never said by anything a person sees.
16
+ *
17
+ * Deliberately probe-based rather than lsof-based: two questions (does TCP connect, does `/status`
18
+ * answer) separate all three states with no platform-specific code, and they work on Windows, where
19
+ * every `lsof` recipe in this repo does not. Naming the holding process is a nicer message and a
20
+ * strictly harder problem; it is not needed to stop lying.
21
+ */
22
+ export const PortPresence = {
23
+ /** A Reticle daemon answered `/status`. The only usable state. */
24
+ DAEMON: 'daemon',
25
+ /**
26
+ * Something accepts connections here and does not serve Reticle's status endpoint.
27
+ *
28
+ * A foreign process, or one of our own daemons wedged mid-start — and those are the same fact for
29
+ * every caller: this port cannot be bound and cannot be used.
30
+ */
31
+ FOREIGN: 'foreign',
32
+ /** Nothing is listening. */
33
+ FREE: 'free',
34
+ };
35
+ /**
36
+ * Pure. The probes live at the call sites (`probeDaemon`, `fetchStatus`) so this stays testable
37
+ * without a socket, and so a probe that starts lying is a separate failure from a rule that does.
38
+ */
39
+ export function classifyPort(facts) {
40
+ if (facts.statusAnswered) {
41
+ if (!facts.tcpOpen) {
42
+ // Not reachable from a real pair of probes. Folding it into DAEMON would mean a broken TCP
43
+ // probe reports a healthy daemon — the exact shape of confident-wrong this module exists to
44
+ // remove — so it is an error rather than a guess.
45
+ throw new Error('a port cannot answer /status without accepting a TCP connection; one of the probes is wrong');
46
+ }
47
+ return PortPresence.DAEMON;
48
+ }
49
+ return facts.tcpOpen ? PortPresence.FOREIGN : PortPresence.FREE;
50
+ }
51
+ /** True only for a daemon that answered. Anything else must not be treated as "running". */
52
+ export function presenceIsUsable(presence) {
53
+ return presence === PortPresence.DAEMON;
54
+ }
55
+ /**
56
+ * Ask the port both questions and classify the answers.
57
+ *
58
+ * The TCP probe is first and cheap; `/status` is only asked when something accepted, which keeps the
59
+ * common "nothing there" case to one failed connect. Impure by necessity — the rule above is where
60
+ * the behaviour lives, and it is tested without a socket.
61
+ */
62
+ export async function probePresence(port, probes) {
63
+ const tcpOpen = await probes.tcpOpen(port);
64
+ if (!tcpOpen)
65
+ return PortPresence.FREE;
66
+ const status = await probes.status(port);
67
+ return classifyPort({ tcpOpen, statusAnswered: status !== undefined });
68
+ }
69
+ /** One sentence a human can act on, for each state. Exhaustive by switch, so a new state must add one. */
70
+ export function describePresence(presence, port, holder) {
71
+ switch (presence) {
72
+ case PortPresence.DAEMON:
73
+ return `a Reticle daemon is serving :${String(port)}`;
74
+ case PortPresence.FOREIGN:
75
+ // Our own daemon, wedged: it accepts TCP and never answers `/status`, so it classifies as
76
+ // FOREIGN — correct for binding, and a lie in prose. Found by SIGSTOPping a daemon: doctor
77
+ // said "which is not a Reticle daemon" about a pid its own pid file named, and sent the
78
+ // reader hunting a stranger while never mentioning the fix that works.
79
+ if (holder?.ourPid !== null &&
80
+ holder?.ourPid !== undefined &&
81
+ holder.ourPid === holder.holderPid) {
82
+ return (`port ${String(port)} is held by YOUR Reticle daemon (pid ${String(holder.ourPid)}), ` +
83
+ 'and it is not responding — it is running but wedged, so nothing can use it and nothing ' +
84
+ 'else can bind the port. Stop it (`reticle stop`) and the next tool call starts a fresh ' +
85
+ 'one.');
86
+ }
87
+ return (`port ${String(port)} is held by another process that is not a Reticle daemon — ` +
88
+ 'a daemon cannot bind it. Stop that process, or run Reticle on a different port ' +
89
+ '(`--port`), then retry.');
90
+ case PortPresence.FREE:
91
+ return `nothing is listening on :${String(port)} — no daemon is running`;
92
+ }
93
+ }