@reticlehq/server 3.8.2 → 3.9.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 (224) hide show
  1. package/LICENSE +201 -104
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/bin/launch.js +13 -0
  5. package/bin/package.json +3 -0
  6. package/bin/reticle.js +66 -8
  7. package/dist/chromium-launch-options.d.ts +25 -0
  8. package/dist/chromium-launch-options.js +21 -1
  9. package/dist/command/cli/adhoc-verdict.js +34 -7
  10. package/dist/command/cli/answers/gate-exit.d.ts +6 -2
  11. package/dist/command/cli/answers/gate-exit.js +12 -2
  12. package/dist/command/cli/ci-hint.d.ts +44 -0
  13. package/dist/command/cli/ci-hint.js +72 -0
  14. package/dist/command/cli/cli-doctor.d.ts +6 -1
  15. package/dist/command/cli/cli-doctor.js +45 -4
  16. package/dist/command/cli/cli-flow-commands.d.ts +7 -1
  17. package/dist/command/cli/cli-flow-commands.js +39 -17
  18. package/dist/command/cli/cli-kill.d.ts +21 -0
  19. package/dist/command/cli/cli-kill.js +46 -3
  20. package/dist/command/cli/cli-parse.d.ts +5 -2
  21. package/dist/command/cli/cli-parse.js +10 -5
  22. package/dist/command/cli/cli-usage.js +3 -2
  23. package/dist/command/cli/cli-verify.d.ts +6 -1
  24. package/dist/command/cli/cli-verify.js +32 -13
  25. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
  26. package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
  27. package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
  28. package/dist/command/cli/doctor/doctor-fix.js +89 -0
  29. package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
  30. package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
  31. package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
  32. package/dist/command/cli/doctor/doctor-rows.js +4 -0
  33. package/dist/command/cli/doctor-repairs.d.ts +64 -0
  34. package/dist/command/cli/doctor-repairs.js +214 -0
  35. package/dist/command/cli/drive/drive-command.js +3 -7
  36. package/dist/command/cli/first-use-checks.d.ts +41 -0
  37. package/dist/command/cli/first-use-checks.js +127 -0
  38. package/dist/command/cli/first-verdict-node.d.ts +3 -0
  39. package/dist/command/cli/first-verdict-node.js +189 -0
  40. package/dist/command/cli/first-verdict.d.ts +126 -0
  41. package/dist/command/cli/first-verdict.js +267 -0
  42. package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
  43. package/dist/command/cli/launch/sdk-version-match.js +3 -2
  44. package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
  45. package/dist/command/cli/mcp-command.js +13 -4
  46. package/dist/command/cli/ports/port-holder.d.ts +2 -0
  47. package/dist/command/cli/ports/port-holder.js +11 -3
  48. package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
  49. package/dist/command/cli/setup-mcp-cli.js +98 -23
  50. package/dist/command/cli/status/status-lines.d.ts +1 -0
  51. package/dist/command/cli/status/status-lines.js +50 -17
  52. package/dist/command/cli/status-fields.js +17 -3
  53. package/dist/command/cli/try-command.d.ts +7 -0
  54. package/dist/command/cli/try-command.js +11 -3
  55. package/dist/command/cli.js +70 -33
  56. package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
  57. package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
  58. package/dist/command/daemon/daemon-argv.d.ts +9 -0
  59. package/dist/command/daemon/daemon-argv.js +11 -0
  60. package/dist/command/daemon/daemon.d.ts +10 -3
  61. package/dist/command/daemon/daemon.js +17 -6
  62. package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
  63. package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
  64. package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
  65. package/dist/command/daemon/lifetime/death-cause.js +130 -0
  66. package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
  67. package/dist/command/daemon/lifetime/heartbeat.js +4 -13
  68. package/dist/command/daemon/port-state.d.ts +14 -0
  69. package/dist/command/daemon/port-state.js +31 -0
  70. package/dist/command/setup/agent-configs.d.ts +2 -0
  71. package/dist/command/setup/agent-configs.js +42 -15
  72. package/dist/command/setup/agent-next-steps.d.ts +77 -0
  73. package/dist/command/setup/agent-next-steps.js +284 -0
  74. package/dist/command/setup/agent-writer.js +11 -6
  75. package/dist/command/setup/init/init-host.js +2 -0
  76. package/dist/command/setup/init/init-runtime.d.ts +6 -1
  77. package/dist/command/setup/init/init-runtime.js +57 -21
  78. package/dist/command/setup/mcp-launcher.d.ts +3 -0
  79. package/dist/command/setup/mcp-launcher.js +11 -0
  80. package/dist/command/setup/setup-command.js +30 -12
  81. package/dist/command/setup/setup-mcp.d.ts +8 -0
  82. package/dist/command/setup/setup-mcp.js +47 -20
  83. package/dist/command/setup/start-dev-server.d.ts +8 -0
  84. package/dist/command/setup/start-dev-server.js +43 -0
  85. package/dist/command/version/identity/server-version.d.ts +2 -0
  86. package/dist/command/version/identity/server-version.js +2 -0
  87. package/dist/command/version/version-skew.d.ts +7 -0
  88. package/dist/command/version/version-skew.js +13 -2
  89. package/dist/features/license/license.d.ts +11 -71
  90. package/dist/features/license/license.js +43 -136
  91. package/dist/features/visual/desktop-capture.d.ts +33 -0
  92. package/dist/features/visual/desktop-capture.js +94 -0
  93. package/dist/features/visual/step-shots.js +139 -35
  94. package/dist/features/visual/visual-tools.d.ts +0 -18
  95. package/dist/features/visual/visual-tools.js +2 -81
  96. package/dist/import-graph.js +5 -6
  97. package/dist/index.d.ts +3 -1
  98. package/dist/index.js +14 -5
  99. package/dist/judgement/runs/run-ledger.d.ts +7 -5
  100. package/dist/judgement/runs/run-ledger.js +98 -69
  101. package/dist/judgement/runs/verification-sync.d.ts +6 -3
  102. package/dist/judgement/runs/verification-sync.js +21 -10
  103. package/dist/language/flows/flow-store-for-session.js +7 -5
  104. package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
  105. package/dist/language/flows/recording/tape/recordings.js +41 -44
  106. package/dist/language/flows/replay.d.ts +17 -7
  107. package/dist/language/flows/replay.js +95 -44
  108. package/dist/launch-chromium.d.ts +1 -0
  109. package/dist/launch-chromium.js +49 -12
  110. package/dist/memory/cloud/sync-screenshots.js +6 -1
  111. package/dist/portal/bridge/bridge.d.ts +13 -4
  112. package/dist/portal/bridge/bridge.js +10 -9
  113. package/dist/portal/input/network-mock.js +3 -2
  114. package/dist/portal/input/real-input.d.ts +15 -0
  115. package/dist/portal/input/real-input.js +59 -3
  116. package/dist/portal/input/viewport-tools.js +6 -3
  117. package/dist/portal/pool/browser-pool.d.ts +5 -1
  118. package/dist/portal/pool/browser-pool.js +32 -38
  119. package/dist/portal/pool/context-permissions.d.ts +20 -17
  120. package/dist/portal/pool/context-permissions.js +63 -1
  121. package/dist/portal/pool/csp-widen.d.ts +62 -0
  122. package/dist/portal/pool/csp-widen.js +132 -0
  123. package/dist/portal/pool/lease-page-error.d.ts +29 -0
  124. package/dist/portal/pool/lease-page-error.js +18 -0
  125. package/dist/portal/pool/page-error-journal.d.ts +57 -0
  126. package/dist/portal/pool/page-error-journal.js +181 -0
  127. package/dist/portal/pool/playwright-launcher.js +21 -8
  128. package/dist/portal/pool/pool-contract.d.ts +22 -0
  129. package/dist/portal/pool/pool-lease.d.ts +35 -2
  130. package/dist/portal/pool/pool-lease.js +23 -0
  131. package/dist/portal/pool/zero-install.js +69 -1
  132. package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
  133. package/dist/portal/session/dev-server/dev-command.js +56 -8
  134. package/dist/portal/session/no-session-diagnosis.js +10 -3
  135. package/dist/portal/session/no-session-next-action.d.ts +12 -0
  136. package/dist/portal/session/no-session-next-action.js +44 -4
  137. package/dist/portal/session/no-session-watch.js +3 -1
  138. package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
  139. package/dist/portal/session/page-commands/undeclared-command.js +16 -7
  140. package/dist/portal/session/recorded-gaps.d.ts +1 -1
  141. package/dist/portal/session/recorded-gaps.js +5 -2
  142. package/dist/portal/session/session-manager.js +40 -21
  143. package/dist/portal/session/session.js +4 -3
  144. package/dist/portal/session/zero-install-first-call.d.ts +20 -1
  145. package/dist/portal/session/zero-install-first-call.js +34 -2
  146. package/dist/start-wiring.d.ts +29 -2
  147. package/dist/start-wiring.js +73 -9
  148. package/dist/surface/mcp/mcp-proxy.js +10 -2
  149. package/dist/surface/mcp/mcp.js +5 -3
  150. package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
  151. package/dist/surface/mcp/peer/agents-connected.js +58 -0
  152. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
  153. package/dist/surface/tools/act/act-preflight.js +37 -17
  154. package/dist/surface/tools/act/act-target.js +3 -1
  155. package/dist/surface/tools/act/after-match.d.ts +7 -0
  156. package/dist/surface/tools/act/after-match.js +33 -0
  157. package/dist/surface/tools/act/resolve-target.d.ts +2 -0
  158. package/dist/surface/tools/act/resolve-target.js +35 -8
  159. package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
  160. package/dist/surface/tools/act/sequence-grade.js +8 -1
  161. package/dist/surface/tools/act-sequence-tool.js +3 -0
  162. package/dist/surface/tools/act-tools.js +10 -10
  163. package/dist/surface/tools/assert/assert-verdict.js +28 -1
  164. package/dist/surface/tools/browser-tools.js +75 -7
  165. package/dist/surface/tools/dynamic-tools.js +2 -1
  166. package/dist/surface/tools/empty-session-list.d.ts +2 -0
  167. package/dist/surface/tools/empty-session-list.js +4 -2
  168. package/dist/surface/tools/error-recovery.js +43 -22
  169. package/dist/surface/tools/invoke-tool.js +27 -20
  170. package/dist/surface/tools/lease-hint.d.ts +7 -0
  171. package/dist/surface/tools/lease-hint.js +10 -3
  172. package/dist/surface/tools/lease-readiness.d.ts +21 -0
  173. package/dist/surface/tools/lease-readiness.js +47 -0
  174. package/dist/surface/tools/lease-seed.d.ts +2 -0
  175. package/dist/surface/tools/lease-seed.js +2 -1
  176. package/dist/surface/tools/lease-tools.js +43 -15
  177. package/dist/surface/tools/repeat-hint.d.ts +40 -0
  178. package/dist/surface/tools/repeat-hint.js +171 -0
  179. package/dist/surface/tools/run-report.d.ts +2 -2
  180. package/dist/surface/tools/run-report.js +27 -15
  181. package/dist/surface/tools/tool-kit.d.ts +12 -0
  182. package/dist/surface/tools/tool-kit.js +7 -0
  183. package/dist/surface/tools/tools.js +26 -13
  184. package/dist/telemetry/argument-shape.js +16 -7
  185. package/dist/telemetry/cli-telemetry.d.ts +10 -1
  186. package/dist/telemetry/cli-telemetry.js +15 -0
  187. package/dist/telemetry/init-telemetry.d.ts +2 -1
  188. package/dist/telemetry/init-telemetry.js +47 -2
  189. package/dist/telemetry/install-trace.js +23 -4
  190. package/dist/telemetry/onboarding-funnel.d.ts +19 -2
  191. package/dist/telemetry/onboarding-funnel.js +25 -3
  192. package/dist/telemetry/proxy-telemetry.d.ts +2 -1
  193. package/dist/telemetry/proxy-telemetry.js +2 -2
  194. package/dist/telemetry/session-metrics.d.ts +1 -1
  195. package/dist/telemetry/session-metrics.js +67 -27
  196. package/dist/telemetry/telemetry.d.ts +3 -1
  197. package/dist/telemetry/telemetry.js +60 -28
  198. package/dist/telemetry/tool-refused.d.ts +2 -2
  199. package/dist/telemetry/tool-refused.js +9 -2
  200. package/docs/architecture.md +3 -6
  201. package/docs/cli/doctor.mdx +41 -2
  202. package/docs/cli/help.mdx +4 -2
  203. package/docs/cli/license.mdx +4 -5
  204. package/docs/cli/setup.mdx +13 -2
  205. package/docs/cli/status.mdx +4 -0
  206. package/docs/cli.mdx +1 -1
  207. package/docs/deploy-checks.md +25 -0
  208. package/docs/desktop.mdx +1 -1
  209. package/docs/enterprise.md +15 -20
  210. package/docs/environment.mdx +1 -0
  211. package/docs/first-drive.md +1 -1
  212. package/docs/install-manual.mdx +16 -1
  213. package/docs/packages/engine.mdx +1 -1
  214. package/docs/packages/server.mdx +1 -1
  215. package/docs/packages/tauri.mdx +1 -1
  216. package/docs/packages/test.mdx +1 -1
  217. package/docs/packages.mdx +1 -1
  218. package/docs/platform-integration.md +1 -1
  219. package/docs/skill.md +1 -1
  220. package/docs/telemetry.md +4 -2
  221. package/package.json +13 -9
  222. package/LICENSE-ENTERPRISE +0 -37
  223. package/dist/features/ee/audit-log.d.ts +0 -14
  224. package/dist/features/ee/audit-log.js +0 -11
@@ -9,7 +9,9 @@
9
9
  */
10
10
  export declare function fetchStatus(port: number, host?: string,
11
11
  /** Also tell the daemon a setup run is waiting on it, so it does not idle out mid-wait. */
12
- hold?: boolean): Promise<unknown>;
12
+ hold?: boolean,
13
+ /** A longer limit, for a caller that must not mistake a busy daemon for a stuck one. */
14
+ timeoutMs?: number): Promise<unknown>;
13
15
  /** One connected tab as `reticle status` reports it — the at-a-glance health line. */
14
16
  interface StatusSession {
15
17
  sessionId: string;
@@ -42,13 +42,15 @@ const ReticleStatusSchema = z
42
42
  */
43
43
  export function fetchStatus(port, host = LOOPBACK_HOST,
44
44
  /** Also tell the daemon a setup run is waiting on it, so it does not idle out mid-wait. */
45
- hold = false) {
45
+ hold = false,
46
+ /** A longer limit, for a caller that must not mistake a busy daemon for a stuck one. */
47
+ timeoutMs = STATUS_PROBE_TIMEOUT_MS) {
46
48
  return new Promise((resolve) => {
47
49
  const req = http.get({
48
50
  host,
49
51
  port,
50
52
  path: hold ? `${STATUS_PATH}?${STATUS_HOLD_QUERY}=1` : STATUS_PATH,
51
- timeout: STATUS_PROBE_TIMEOUT_MS,
53
+ timeout: timeoutMs,
52
54
  // Shares the proxy's keep-alive agent: the proxy calls this on every reconnect, and a socket
53
55
  // per probe is the same churn the POST leg was fixed for.
54
56
  agent: loopbackAgent,
@@ -0,0 +1,9 @@
1
+ /** The argv word that runs the daemon itself. `serve` and the MCP proxy spawn it; a person never types it. */
2
+ export declare const DAEMON_INNER_COMMAND = "_daemon";
3
+ /**
4
+ * True when `commandLine` runs a Reticle daemon (#1937).
5
+ *
6
+ * The MCP proxy (`reticle mcp`) and every other process fail this test, so nothing that relies on
7
+ * it can stop the agent's proxy or a stranger's process.
8
+ */
9
+ export declare function isReticleDaemonCommand(commandLine: string): boolean;
@@ -0,0 +1,11 @@
1
+ /** The argv word that runs the daemon itself. `serve` and the MCP proxy spawn it; a person never types it. */
2
+ export const DAEMON_INNER_COMMAND = '_daemon';
3
+ /**
4
+ * True when `commandLine` runs a Reticle daemon (#1937).
5
+ *
6
+ * The MCP proxy (`reticle mcp`) and every other process fail this test, so nothing that relies on
7
+ * it can stop the agent's proxy or a stranger's process.
8
+ */
9
+ export function isReticleDaemonCommand(commandLine) {
10
+ return commandLine.trim().split(/\s+/).includes(DAEMON_INNER_COMMAND);
11
+ }
@@ -1,3 +1,4 @@
1
+ import { type DeathCause } from './lifetime/death-cause.js';
1
2
  import { type EscapeRequest, type EscapeResult } from './win32-escape.js';
2
3
  export { MAX_DAEMON_LOG_BYTES, recoverOversizedLog } from './lifetime/log-cap.js';
3
4
  /** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
@@ -22,6 +23,11 @@ export declare function rotateDaemonLog(path: string, deps: {
22
23
  renameFile(from: string, to: string): void;
23
24
  }, max?: number): boolean;
24
25
  export declare function readPid(port: number, home?: string): number | null;
26
+ /**
27
+ * Whether a process with this pid exists. `EPERM` means it exists and this process may not signal
28
+ * it, which a daemon that WMI started can answer on Windows; only `ESRCH` means it is gone. Reading
29
+ * every throw as "dead" reclaimed a live daemon and started a second one on its port.
30
+ */
25
31
  export declare function isAlive(pid: number): boolean;
26
32
  export declare function writePid(port: number): void;
27
33
  /**
@@ -81,10 +87,11 @@ export interface SpawnDaemonDeps {
81
87
  /** Windows only: start the daemon outside the Job Object of this process. See win32-escape.ts. */
82
88
  readonly escapeJob?: (request: EscapeRequest) => EscapeResult;
83
89
  /**
84
- * A predecessor died without a clean stop; `ageMs` is how long it had run. Optional so a test that
85
- * does not care reports nothing. The default sends `daemon_died_unclean`.
90
+ * A predecessor died without a clean stop; `ageMs` is how long it had run, and `cause` is what its
91
+ * own log says about how it ended. Optional so a test that does not care reports nothing. The
92
+ * default sends `daemon_died_unclean`.
86
93
  */
87
- onUncleanDeath?(ageMs: number): void;
94
+ onUncleanDeath?(ageMs: number, cause?: DeathCause): void;
88
95
  }
89
96
  /**
90
97
  * Spawn the reticle daemon as a detached background process, redirecting output to the log file.
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { daemonRegistryFileName, DaemonSpawnMethod, ReticleEnv, } from '@reticlehq/core';
6
6
  import { log } from '../../log.js';
7
7
  import { uncleanPredecessor } from './lifetime/unclean-predecessor.js';
8
+ import { deathCauseFromLog, readLogTail } from './lifetime/death-cause.js';
8
9
  import { reportDaemonDiedUnclean } from '../../telemetry/proxy-telemetry.js';
9
10
  import { MAX_DAEMON_LOG_BYTES, pruneOldDaemonLogs } from './lifetime/log-cap.js';
10
11
  import { escapeJobViaWmi } from './win32-escape.js';
@@ -59,13 +60,18 @@ export function readPid(port, home = reticleStateHome()) {
59
60
  const n = parseInt(readFileSync(path, 'utf8').trim(), 10);
60
61
  return isNaN(n) ? null : n;
61
62
  }
63
+ /**
64
+ * Whether a process with this pid exists. `EPERM` means it exists and this process may not signal
65
+ * it, which a daemon that WMI started can answer on Windows; only `ESRCH` means it is gone. Reading
66
+ * every throw as "dead" reclaimed a live daemon and started a second one on its port.
67
+ */
62
68
  export function isAlive(pid) {
63
69
  try {
64
70
  process.kill(pid, 0);
65
71
  return true;
66
72
  }
67
- catch {
68
- return false;
73
+ catch (err) {
74
+ return err instanceof Error && 'code' in err && 'EPERM' === err.code;
69
75
  }
70
76
  }
71
77
  export function writePid(port) {
@@ -206,17 +212,22 @@ function defaultSpawnDaemonDeps() {
206
212
  pidAlive: isAlive,
207
213
  platform: process.platform,
208
214
  escapeJob: escapeJobViaWmi,
209
- onUncleanDeath: (ageMs) => void reportDaemonDiedUnclean(ageMs),
215
+ onUncleanDeath: (ageMs, cause) => void reportDaemonDiedUnclean(ageMs, cause),
210
216
  };
211
217
  }
212
218
  /**
213
219
  * Report a dead predecessor, aged from its pidfile: the parent writes that file at spawn time.
214
220
  * Read before the unlink, because after it nothing is left to age. A report must never stop a start.
215
221
  */
216
- function reportUncleanAge(pidFilePath, deps) {
222
+ function reportUncleanAge(pidFilePath, logFilePath, deps) {
217
223
  try {
218
224
  // The clock is read at this I/O boundary, the same as the log timestamps in this file.
219
- deps.onUncleanDeath?.(Math.max(0, Date.now() - statSync(pidFilePath).mtimeMs));
225
+ const now = Date.now();
226
+ const startedMs = statSync(pidFilePath).mtimeMs;
227
+ // Read before `rotateDaemonLog`, which renames the file this daemon wrote.
228
+ const tail = readLogTail(logFilePath);
229
+ const cause = tail === undefined ? undefined : deathCauseFromLog(tail, startedMs, now);
230
+ deps.onUncleanDeath?.(Math.max(0, now - startedMs), cause);
220
231
  }
221
232
  catch {
222
233
  // An unreadable pidfile has no age to report.
@@ -283,7 +294,7 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
283
294
  // because after it there is no trace left that the previous daemon died at all.
284
295
  orphanPid = uncleanPredecessor(existing, false, process.pid);
285
296
  if (null !== orphanPid) {
286
- reportUncleanAge(pidFilePath, deps);
297
+ reportUncleanAge(pidFilePath, logFilePath, deps);
287
298
  }
288
299
  try {
289
300
  unlinkSync(pidFilePath);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The daemon's exit, with a bound (#1937).
3
+ *
4
+ * The exit used to await the telemetry summary and then `server.close()`, with no limit on the
5
+ * close. The close waits for the browser pool, the cloud sync flush and every open HTTP connection.
6
+ * When one of them never settled, the process stayed alive with its port held, and every tool call
7
+ * found a daemon that did not answer.
8
+ */
9
+ export declare const ExitCode: {
10
+ readonly CLEAN: 0;
11
+ readonly FAILED: 1;
12
+ };
13
+ export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
14
+ /**
15
+ * The most the summary and the close get together. The summary send has its own 2s bound, and the
16
+ * pool needs a few seconds to close its browsers.
17
+ */
18
+ export declare const DAEMON_EXIT_BUDGET_MS = 10000;
19
+ /** Send the summary, close, and resolve to the exit code. Never takes longer than `budgetMs`. */
20
+ export declare function exitAfterClose(steps: {
21
+ summary: () => Promise<void>;
22
+ close: () => Promise<void>;
23
+ budgetMs?: number;
24
+ }): Promise<ExitCode>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The daemon's exit, with a bound (#1937).
3
+ *
4
+ * The exit used to await the telemetry summary and then `server.close()`, with no limit on the
5
+ * close. The close waits for the browser pool, the cloud sync flush and every open HTTP connection.
6
+ * When one of them never settled, the process stayed alive with its port held, and every tool call
7
+ * found a daemon that did not answer.
8
+ */
9
+ import { log } from '../../../log.js';
10
+ export const ExitCode = {
11
+ CLEAN: 0,
12
+ FAILED: 1,
13
+ };
14
+ /**
15
+ * The most the summary and the close get together. The summary send has its own 2s bound, and the
16
+ * pool needs a few seconds to close its browsers.
17
+ */
18
+ export const DAEMON_EXIT_BUDGET_MS = 10_000;
19
+ /** Send the summary, close, and resolve to the exit code. Never takes longer than `budgetMs`. */
20
+ export async function exitAfterClose(steps) {
21
+ const budgetMs = steps.budgetMs ?? DAEMON_EXIT_BUDGET_MS;
22
+ let timer;
23
+ const deadline = new Promise((_resolve, reject) => {
24
+ timer = setTimeout(() => {
25
+ reject(new Error(`the daemon did not finish its shutdown within ${String(budgetMs)}ms`));
26
+ }, budgetMs);
27
+ });
28
+ try {
29
+ await Promise.race([steps.summary().then(steps.close), deadline]);
30
+ return ExitCode.CLEAN;
31
+ }
32
+ catch (err) {
33
+ log('reticle_daemon_close_error', { error: err instanceof Error ? err.message : String(err) });
34
+ return ExitCode.FAILED;
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
@@ -0,0 +1,15 @@
1
+ import { DaemonEnd, type DaemonDeath } from '@reticlehq/core';
2
+ /** What the log says about the dead daemon. `ageMs` is the caller's to add. */
3
+ export type DeathCause = Omit<DaemonDeath, 'ageMs'> & {
4
+ end: DaemonEnd;
5
+ };
6
+ /**
7
+ * Read the cause of death of the daemon that started at `startedMs`.
8
+ *
9
+ * The log is append-only across launches, so only lines at or after `startedMs` count. A daemon
10
+ * that crashed before it wrote any line leaves only a raw Node stack, after the last line of the
11
+ * daemon before it; the error code is read from there.
12
+ */
13
+ export declare function deathCauseFromLog(text: string, startedMs: number, nowMs: number): DeathCause;
14
+ /** The last part of the log at `path`, or undefined when it cannot be read. Never throws. */
15
+ export declare function readLogTail(path: string): string | undefined;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Why a daemon died, read from its own log by the start that finds it dead.
3
+ *
4
+ * The daemon log already says how a daemon ended: a heartbeat every 30 s, a line for a signal, a
5
+ * line for an exit, and a line for a failed start (see heartbeat.ts). Nobody uploads that file, so
6
+ * `daemon_died_unclean` carried only the age, and a reboot, a killed process tree, an out-of-memory
7
+ * kill and a crash at startup sent the same event. The next start reads the log before it rotates
8
+ * it, and sends the cause with the event.
9
+ *
10
+ * Only facts that are names or numbers leave this module: an end, a signal name, a Node error code,
11
+ * counts. Never a message, because a message can carry the user's paths.
12
+ */
13
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
14
+ import { DaemonEnd, NODE_ERROR_CODE } from '@reticlehq/core';
15
+ import { DAEMON_HEARTBEAT_EVENT, classifyDaemonLife } from './heartbeat.js';
16
+ import { DAEMON_START_FAILED_EVENT } from './startup-failure.js';
17
+ /**
18
+ * A Node error code where Node writes one: `[ERR_MODULE_NOT_FOUND]`, `code: 'EADDRINUSE'`, or
19
+ * `listen EADDRINUSE:`. A bare upper-case word elsewhere is not a code.
20
+ */
21
+ const ERROR_CODE_IN_TEXT = /\b(E[A-Z]{3,30}|ERR_[A-Z0-9_]{2,60})(?=\]|'|"|:)/g;
22
+ /** Upper-case words that sit before a colon and are not codes. */
23
+ const NOT_A_CODE = new Set(['ERROR', 'EXIT', 'ENV']);
24
+ function parse(raw) {
25
+ let record;
26
+ try {
27
+ record = JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ if ('object' !== typeof record || null === record) {
33
+ return undefined;
34
+ }
35
+ const r = record;
36
+ const t = 'string' === typeof r['t'] ? Date.parse(r['t']) : Number.NaN;
37
+ if (!Number.isFinite(t) || 'string' !== typeof r['event']) {
38
+ return undefined;
39
+ }
40
+ return { ...r, t, event: r['event'] };
41
+ }
42
+ /** The last Node error code in `text`, or undefined. Only the code, never the text around it. */
43
+ function lastErrorCode(text) {
44
+ const codes = [...text.matchAll(ERROR_CODE_IN_TEXT)].map((m) => m[1]);
45
+ const code = codes.filter((c) => c !== undefined && !NOT_A_CODE.has(c)).at(-1);
46
+ return code !== undefined && NODE_ERROR_CODE.test(code) ? code : undefined;
47
+ }
48
+ const count = (v) => 'number' === typeof v && Number.isInteger(v) && 0 <= v ? v : undefined;
49
+ /**
50
+ * Read the cause of death of the daemon that started at `startedMs`.
51
+ *
52
+ * The log is append-only across launches, so only lines at or after `startedMs` count. A daemon
53
+ * that crashed before it wrote any line leaves only a raw Node stack, after the last line of the
54
+ * daemon before it; the error code is read from there.
55
+ */
56
+ export function deathCauseFromLog(text, startedMs, nowMs) {
57
+ const ours = [];
58
+ const rawAfterLastLine = [];
59
+ const rawInOurs = [];
60
+ for (const raw of text.split('\n')) {
61
+ const trimmed = raw.trim();
62
+ if (0 === trimmed.length) {
63
+ continue;
64
+ }
65
+ const parsed = parse(trimmed);
66
+ if (parsed === undefined) {
67
+ (0 < ours.length ? rawInOurs : rawAfterLastLine).push(trimmed);
68
+ continue;
69
+ }
70
+ if (parsed.t >= startedMs) {
71
+ ours.push(parsed);
72
+ }
73
+ else {
74
+ rawAfterLastLine.length = 0;
75
+ }
76
+ }
77
+ const life = classifyDaemonLife(ours, nowMs);
78
+ const cause = { end: life.end };
79
+ if (life.silentForMs !== undefined) {
80
+ cause.silentMs = Math.max(0, Math.round(life.silentForMs));
81
+ }
82
+ if (life.signal !== undefined && /^SIG[A-Z0-9]{1,12}$/.test(life.signal)) {
83
+ cause.signal = life.signal;
84
+ }
85
+ const lastBeat = ours.findLast((l) => l.event === DAEMON_HEARTBEAT_EVENT);
86
+ if (lastBeat !== undefined) {
87
+ const sessions = count(lastBeat['sessions']);
88
+ const rssMb = count(lastBeat['rssMb']);
89
+ if (sessions !== undefined) {
90
+ cause.sessions = sessions;
91
+ }
92
+ if ('boolean' === typeof lastBeat['served']) {
93
+ cause.served = lastBeat['served'];
94
+ }
95
+ if (rssMb !== undefined) {
96
+ cause.rssMb = rssMb;
97
+ }
98
+ }
99
+ const failed = ours.findLast((l) => l.event === DAEMON_START_FAILED_EVENT);
100
+ const error = ('string' === typeof failed?.['error'] ? lastErrorCode(failed['error']) : undefined) ??
101
+ // Raw lines before this daemon's first line belong to the daemon before it, unless this one
102
+ // wrote no line at all, which is a crash before its first log line.
103
+ lastErrorCode((0 < ours.length ? rawInOurs : rawAfterLastLine).join('\n'));
104
+ if (error !== undefined) {
105
+ cause.error = error;
106
+ }
107
+ return cause;
108
+ }
109
+ /** How much of the end of a daemon log the next start reads. The last beats and a stack fit. */
110
+ const LOG_TAIL_BYTES = 256 * 1024;
111
+ /** The last part of the log at `path`, or undefined when it cannot be read. Never throws. */
112
+ export function readLogTail(path) {
113
+ let fd;
114
+ try {
115
+ fd = openSync(path, 'r');
116
+ const size = fstatSync(fd).size;
117
+ const length = Math.min(size, LOG_TAIL_BYTES);
118
+ const buffer = Buffer.alloc(length);
119
+ readSync(fd, buffer, 0, length, size - length);
120
+ return buffer.toString('utf8');
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ finally {
126
+ if (fd !== undefined) {
127
+ closeSync(fd);
128
+ }
129
+ }
130
+ }
@@ -17,6 +17,7 @@
17
17
  *
18
18
  * A heartbeat nobody interprets is just log volume.
19
19
  */
20
+ import { DaemonEnd } from '@reticlehq/core';
20
21
  /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
21
22
  export declare const DAEMON_HEARTBEAT_EVENT = "reticle_daemon_alive";
22
23
  /**
@@ -43,6 +44,11 @@ interface HeartbeatFacts {
43
44
  * and make a long-lived daemon look freshly idle.
44
45
  */
45
46
  served: boolean;
47
+ /**
48
+ * Resident memory in MB. Optional, so a caller that cannot read it sends no number rather than
49
+ * a guess. The next start reports it when this daemon dies, which separates a memory death.
50
+ */
51
+ rssMb?: number;
46
52
  }
47
53
  interface DaemonHeartbeatOptions {
48
54
  log: (event: string, fields: Record<string, unknown>) => void;
@@ -74,20 +80,7 @@ export declare class DaemonHeartbeat {
74
80
  start(): void;
75
81
  stop(): void;
76
82
  }
77
- /** How a daemon's life ended, as far as its own log can say. */
78
- export declare const DaemonEnd: {
79
- /** Still beating within tolerance. */
80
- readonly ALIVE: "alive";
81
- /** Logged its own exit, with no signal before it. */
82
- readonly CLEAN: "clean";
83
- /** A signal arrived — the cause an exit line with `code: 0` does not carry. */
84
- readonly SIGNALLED: "signalled";
85
- /** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
86
- readonly DIED_SILENTLY: "died_silently";
87
- /** No beat was ever emitted, so this log cannot answer the question. */
88
- readonly UNKNOWN: "unknown";
89
- };
90
- export type DaemonEnd = (typeof DaemonEnd)[keyof typeof DaemonEnd];
83
+ export { DaemonEnd };
91
84
  /**
92
85
  * One parsed line of a daemon log. The index signature is not laziness: these lines carry whatever
93
86
  * their event needed (`code`, `port`, `pid`, `reason`, …), and a reader that refused unknown keys
@@ -116,4 +109,3 @@ export interface DaemonLife {
116
109
  * signal we never sent is the same confident-wrong this module replaces.
117
110
  */
118
111
  export declare function classifyDaemonLife(lines: readonly LogLine[], nowMs: number): DaemonLife;
119
- export {};
@@ -17,6 +17,7 @@
17
17
  *
18
18
  * A heartbeat nobody interprets is just log volume.
19
19
  */
20
+ import { DaemonEnd } from '@reticlehq/core';
20
21
  import { DAEMON_LOG_TRUNCATED_EVENT, guardDaemonLog } from './log-cap.js';
21
22
  /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
22
23
  export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
@@ -90,6 +91,7 @@ export class DaemonHeartbeat {
90
91
  everyMs: this.#intervalMs,
91
92
  sessions: facts.sessions,
92
93
  served: facts.served,
94
+ ...(facts.rssMb === undefined ? {} : { rssMb: facts.rssMb }),
93
95
  });
94
96
  }
95
97
  start() {
@@ -106,19 +108,8 @@ export class DaemonHeartbeat {
106
108
  this.#timer = undefined;
107
109
  }
108
110
  }
109
- /** How a daemon's life ended, as far as its own log can say. */
110
- export const DaemonEnd = {
111
- /** Still beating within tolerance. */
112
- ALIVE: 'alive',
113
- /** Logged its own exit, with no signal before it. */
114
- CLEAN: 'clean',
115
- /** A signal arrived — the cause an exit line with `code: 0` does not carry. */
116
- SIGNALLED: 'signalled',
117
- /** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
118
- DIED_SILENTLY: 'died_silently',
119
- /** No beat was ever emitted, so this log cannot answer the question. */
120
- UNKNOWN: 'unknown',
121
- };
111
+ // The vocabulary lives in core, because `daemon_died_unclean` reports it too.
112
+ export { DaemonEnd };
122
113
  /**
123
114
  * Read a daemon log back and say how it ended.
124
115
  *
@@ -0,0 +1,14 @@
1
+ /**
2
+ * What holds the bridge port, said the same way by every surface (#1937).
3
+ *
4
+ * A Reticle daemon stuck in its exit held the port and answered nothing. `status` called the port
5
+ * free, the MCP proxy called the holder "not a Reticle daemon", and `restart` refused to stop it.
6
+ * The pid file did not name it, because the next start had replaced that file. Its command line
7
+ * still did.
8
+ */
9
+ import { type PortHolder } from '../cli/ports/port-holder.js';
10
+ import { PortPresence } from './binding/port-presence.js';
11
+ /** The listener on `port`, and whether its command line is a Reticle daemon's. */
12
+ export declare function identifyPortHolder(port: number, exec: (command: string, args: readonly string[]) => string | null): PortHolder | null;
13
+ /** The sentence for the state of `port`, with the holder named when something holds it. */
14
+ export declare function describePortState(presence: PortPresence, port: number): string;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What holds the bridge port, said the same way by every surface (#1937).
3
+ *
4
+ * A Reticle daemon stuck in its exit held the port and answered nothing. `status` called the port
5
+ * free, the MCP proxy called the holder "not a Reticle daemon", and `restart` refused to stop it.
6
+ * The pid file did not name it, because the next start had replaced that file. Its command line
7
+ * still did.
8
+ */
9
+ import { captureLookup, describeForeignHolder, findPortHolder, } from '../cli/ports/port-holder.js';
10
+ import { describePresence, PortPresence } from './binding/port-presence.js';
11
+ import { isReticleDaemonCommand } from './daemon-argv.js';
12
+ import { readPid } from './daemon.js';
13
+ /** The listener on `port`, and whether its command line is a Reticle daemon's. */
14
+ export function identifyPortHolder(port, exec) {
15
+ const holder = findPortHolder(port, exec);
16
+ if (null === holder) {
17
+ return null;
18
+ }
19
+ const commandLine = exec('ps', ['-o', 'command=', '-p', String(holder.pid)]);
20
+ return {
21
+ ...holder,
22
+ reticleDaemon: null !== commandLine && isReticleDaemonCommand(commandLine),
23
+ };
24
+ }
25
+ /** The sentence for the state of `port`, with the holder named when something holds it. */
26
+ export function describePortState(presence, port) {
27
+ if (PortPresence.FOREIGN !== presence) {
28
+ return describePresence(presence, port);
29
+ }
30
+ return describeForeignHolder(port, identifyPortHolder(port, captureLookup), readPid(port));
31
+ }
@@ -82,6 +82,8 @@ interface AgentPlanInput {
82
82
  readonly platform: keyof PlatformPaths;
83
83
  readonly exists: (path: string) => boolean;
84
84
  readonly readFile: (path: string) => string;
85
+ /** The installed bin to register (`InitHost.mcpLauncher`). Absent: the pinned npx command. */
86
+ readonly launcher?: string | undefined;
85
87
  }
86
88
  /**
87
89
  * Join for the platform the CALLER named, never the one this process happens to run on.
@@ -19,6 +19,7 @@
19
19
  * not ours to do.
20
20
  */
21
21
  import { posix, win32 } from 'node:path';
22
+ import { isReticleRegistration, serverInvocation } from '@reticlehq/init';
22
23
  export const AgentConfidence = {
23
24
  /** Documented by the vendor. Safe to create before the agent exists. */
24
25
  OFFICIAL: 'official',
@@ -34,11 +35,16 @@ export const AgentAction = {
34
35
  SKIP: 'skip',
35
36
  };
36
37
  const NPX = 'npx';
37
- const SERVER_PACKAGE = '@reticlehq/server';
38
- const MCP_SUBCOMMAND = 'mcp';
38
+ /** In every entry we write: the npx package name, or the installed launcher's path. */
39
+ const RETICLE_SCOPE = '@reticlehq';
39
40
  const RETICLE_KEY = 'reticle';
40
- /** The stdio entry, in the shape most clients expect. */
41
- const STDIO = { command: NPX, args: [SERVER_PACKAGE, MCP_SUBCOMMAND] };
41
+ /** The stdio `{ command, args }` that starts the MCP server. One rule for init and setup. */
42
+ function stdioFor(launcher) {
43
+ const [command = NPX, ...args] = serverInvocation(launcher);
44
+ return { command, args };
45
+ }
46
+ /** The stdio entry, in the shape most clients expect. `planAgentConfigs` sets its command. */
47
+ const STDIO = stdioFor();
42
48
  /**
43
49
  * The `PlatformPaths` row to plan for on a host that reports `platform`.
44
50
  *
@@ -234,7 +240,15 @@ export const AGENT_CLIENTS = [
234
240
  * mentions the word in a comment is not mistaken for a registration.
235
241
  */
236
242
  function yamlHasReticle(text) {
237
- return text.includes(`name: ${RETICLE_KEY}`) && text.includes(SERVER_PACKAGE);
243
+ return text.includes(`name: ${RETICLE_KEY}`) && text.includes(RETICLE_SCOPE);
244
+ }
245
+ /** Every string in an entry's `command` and `args`. */
246
+ function entryStrings(entry) {
247
+ if ('object' !== typeof entry || null === entry) {
248
+ return [];
249
+ }
250
+ const { command, args } = entry;
251
+ return [command, args].flat().filter((v) => 'string' === typeof v);
238
252
  }
239
253
  const forPlatform = (p, os) => p[os];
240
254
  /**
@@ -276,6 +290,8 @@ export function planAgentConfigs(input) {
276
290
  const join = joinFor(platform);
277
291
  const file = join(home, forPlatform(c.paths, platform));
278
292
  const detected = exists(join(home, forPlatform(c.marker, platform)));
293
+ const stdio = stdioFor(input.launcher);
294
+ const entry = c.entry === undefined ? undefined : { ...c.entry, ...stdio };
279
295
  // A NEW yaml file has nobody's formatting to destroy. An existing one does: `mcpServers` there
280
296
  // is a list of named entries, and a naive rewrite loses comments, anchors and ordering.
281
297
  if ('yaml-manual' === c.format) {
@@ -299,6 +315,7 @@ export function planAgentConfigs(input) {
299
315
  name: c.name,
300
316
  file,
301
317
  action: AgentAction.CREATE_YAML,
318
+ entry: stdio,
302
319
  why: 'no config yet, so a valid one can be written outright',
303
320
  };
304
321
  }
@@ -319,7 +336,7 @@ export function planAgentConfigs(input) {
319
336
  file,
320
337
  action: AgentAction.CREATE,
321
338
  key: c.key,
322
- entry: c.entry,
339
+ entry,
323
340
  why: detected
324
341
  ? 'installed, with no config yet'
325
342
  : 'a documented path, so a later install finds it already wired',
@@ -339,7 +356,7 @@ export function planAgentConfigs(input) {
339
356
  file,
340
357
  action: AgentAction.MANUAL,
341
358
  key: c.key,
342
- entry: c.entry,
359
+ entry,
343
360
  why: "the existing config is not plain JSON (comments or trailing commas), and rewriting it would reformat somebody else's file",
344
361
  };
345
362
  }
@@ -347,18 +364,28 @@ export function planAgentConfigs(input) {
347
364
  const existing = 'object' === typeof bucket && null !== bucket
348
365
  ? bucket[RETICLE_KEY]
349
366
  : undefined;
350
- if (existing !== undefined) {
351
- const ours = JSON.stringify(existing) === JSON.stringify(c.entry);
367
+ if (existing !== undefined && JSON.stringify(existing) === JSON.stringify(entry)) {
368
+ return {
369
+ id: c.id,
370
+ name: c.name,
371
+ file,
372
+ action: AgentAction.ALREADY,
373
+ key: c.key,
374
+ entry,
375
+ why: 'already registered',
376
+ };
377
+ }
378
+ // An older entry of our own (a floating npx command, a launcher that moved) is replaced. Any
379
+ // other `reticle` entry is somebody's own choice.
380
+ if (existing !== undefined && !isReticleRegistration(entryStrings(existing))) {
352
381
  return {
353
382
  id: c.id,
354
383
  name: c.name,
355
384
  file,
356
- action: ours ? AgentAction.ALREADY : AgentAction.MANUAL,
385
+ action: AgentAction.MANUAL,
357
386
  key: c.key,
358
- entry: c.entry,
359
- why: ours
360
- ? 'already registered'
361
- : 'a different `reticle` entry is there, and it is not ours to replace',
387
+ entry,
388
+ why: 'a different `reticle` entry is there, and it is not ours to replace',
362
389
  };
363
390
  }
364
391
  return {
@@ -367,7 +394,7 @@ export function planAgentConfigs(input) {
367
394
  file,
368
395
  action: AgentAction.MERGE,
369
396
  key: c.key,
370
- entry: c.entry,
397
+ entry,
371
398
  why: 'adding our key beside the servers already there',
372
399
  };
373
400
  });