@reticlehq/server 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/SKILL.md +17 -18
  2. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  3. package/dist/command/cli/cli-doctor.js +13 -1
  4. package/dist/command/cli/cli-flow-commands.d.ts +2 -1
  5. package/dist/command/cli/cli-kill.js +1 -1
  6. package/dist/command/cli/cli-parse.d.ts +2 -1
  7. package/dist/command/cli/cli-parse.js +5 -2
  8. package/dist/command/cli/cli-verify.js +2 -2
  9. package/dist/command/cli/cloud-cli.js +7 -6
  10. package/dist/command/cli/cloud-kit.js +2 -6
  11. package/dist/command/cli/config/config-discovery.d.ts +12 -0
  12. package/dist/command/cli/config/config-discovery.js +19 -0
  13. package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
  14. package/dist/command/cli/doctor/doctor-rows.js +12 -0
  15. package/dist/command/cli/drive/drive-command.js +1 -1
  16. package/dist/command/cli/launch/cli-launch.d.ts +10 -2
  17. package/dist/command/cli/launch/cli-launch.js +32 -39
  18. package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
  19. package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
  20. package/dist/command/cli/mcp-command.js +5 -3
  21. package/dist/command/cli/ports/resolve/cli-port.d.ts +2 -1
  22. package/dist/command/cli/ports/resolve/cli-port.js +5 -1
  23. package/dist/command/cli/status/status-lines.d.ts +28 -0
  24. package/dist/command/cli/status/status-lines.js +117 -0
  25. package/dist/command/cli.d.ts +1 -1
  26. package/dist/command/cli.js +24 -177
  27. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  28. package/dist/command/daemon/binding/daemon-status-probe.js +48 -0
  29. package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
  30. package/dist/command/daemon/binding/loopback-reach.js +63 -0
  31. package/dist/command/daemon/daemon-resilience.d.ts +13 -1
  32. package/dist/command/daemon/daemon-resilience.js +12 -1
  33. package/dist/command/daemon/daemon.d.ts +2 -11
  34. package/dist/command/daemon/daemon.js +8 -11
  35. package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
  36. package/dist/command/daemon/lifetime/heartbeat.js +10 -0
  37. package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
  38. package/dist/command/daemon/lifetime/log-cap.js +115 -0
  39. package/dist/command/setup/agent-notice-memory.d.ts +16 -0
  40. package/dist/command/setup/agent-notice-memory.js +65 -0
  41. package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
  42. package/dist/command/setup/bringup/dev-server-wait.js +16 -0
  43. package/dist/command/setup/bringup/ensure-daemon.js +1 -1
  44. package/dist/command/setup/init/init-runtime.js +13 -1
  45. package/dist/command/setup/node-effects.js +1 -1
  46. package/dist/command/setup/probe/page-probe.js +9 -2
  47. package/dist/command/setup/run-setup.d.ts +25 -7
  48. package/dist/command/setup/run-setup.js +51 -9
  49. package/dist/command/setup/setup-command.js +31 -3
  50. package/dist/command/setup/terminal/confirm.js +2 -1
  51. package/dist/command/update/updater.d.ts +27 -0
  52. package/dist/command/update/updater.js +35 -2
  53. package/dist/features/crawl/crawl-tools.js +3 -1
  54. package/dist/features/crawl/crawl.js +2 -1
  55. package/dist/features/harness/drive-report.d.ts +73 -0
  56. package/dist/features/harness/drive-report.js +196 -0
  57. package/dist/features/harness/driver.js +29 -8
  58. package/dist/features/harness/drivers.d.ts +19 -0
  59. package/dist/features/harness/drivers.js +19 -0
  60. package/dist/features/harness/fill-values.d.ts +67 -0
  61. package/dist/features/harness/fill-values.js +153 -0
  62. package/dist/features/harness/jev-driver.d.ts +188 -0
  63. package/dist/features/harness/jev-driver.js +664 -0
  64. package/dist/features/harness/openai-driver.d.ts +46 -0
  65. package/dist/features/harness/openai-driver.js +197 -0
  66. package/dist/features/harness/platform-config.d.ts +52 -0
  67. package/dist/features/harness/platform-config.js +89 -0
  68. package/dist/features/visual/visual-tools.js +26 -2
  69. package/dist/index.js +60 -55
  70. package/dist/judgement/domain/domain-tools.js +4 -2
  71. package/dist/judgement/runs/verification-sync.d.ts +2 -2
  72. package/dist/judgement/runs/verification-sync.js +9 -2
  73. package/dist/language/flows/affected-tools.js +6 -3
  74. package/dist/language/flows/flow-replay-run.d.ts +25 -2
  75. package/dist/language/flows/flow-replay-run.js +73 -27
  76. package/dist/language/flows/flow-replay.d.ts +17 -0
  77. package/dist/language/flows/flow-replay.js +148 -100
  78. package/dist/language/flows/flow-result.d.ts +18 -1
  79. package/dist/language/flows/flow-result.js +18 -2
  80. package/dist/language/flows/flow-store-for-session.d.ts +2 -1
  81. package/dist/language/flows/flow-tools.js +8 -4
  82. package/dist/language/flows/flows.d.ts +8 -7
  83. package/dist/language/flows/flows.js +7 -3
  84. package/dist/language/flows/narrow-write.d.ts +4 -4
  85. package/dist/language/flows/replay.js +1 -1
  86. package/dist/language/flows/suite-selection.d.ts +15 -1
  87. package/dist/language/flows/suite-selection.js +1 -1
  88. package/dist/language/flows/verify-change-tools.js +7 -4
  89. package/dist/memory/cloud/account-state.d.ts +1 -1
  90. package/dist/memory/cloud/account-state.js +5 -4
  91. package/dist/memory/cloud/cloud-config.d.ts +9 -0
  92. package/dist/memory/cloud/cloud-config.js +9 -0
  93. package/dist/memory/cloud/cloud-sync.d.ts +7 -2
  94. package/dist/memory/cloud/cloud-sync.js +10 -5
  95. package/dist/memory/cloud/harness-config.d.ts +10 -0
  96. package/dist/memory/cloud/harness-config.js +58 -0
  97. package/dist/memory/cloud/harness-offer.d.ts +44 -0
  98. package/dist/memory/cloud/harness-offer.js +146 -0
  99. package/dist/memory/cloud/harness-switch.d.ts +11 -0
  100. package/dist/memory/cloud/harness-switch.js +53 -0
  101. package/dist/memory/cloud/sync-daemon.d.ts +22 -2
  102. package/dist/memory/cloud/sync-daemon.js +24 -0
  103. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  104. package/dist/memory/impact/impact-recorder.js +2 -1
  105. package/dist/memory/impact/impact-store.d.ts +5 -0
  106. package/dist/memory/impact/impact-store.js +19 -0
  107. package/dist/memory/intent/intent-shard-store.js +2 -2
  108. package/dist/memory/intent/intent-shard.d.ts +12 -12
  109. package/dist/memory/journal/attach-journal.d.ts +11 -0
  110. package/dist/memory/journal/attach-journal.js +1 -1
  111. package/dist/memory/journal/journal-query.d.ts +35 -1
  112. package/dist/memory/journal/journal-query.js +46 -0
  113. package/dist/memory/journal/journal-recorder.d.ts +50 -2
  114. package/dist/memory/journal/journal-recorder.js +45 -7
  115. package/dist/memory/journal/on-disk/retention.d.ts +87 -2
  116. package/dist/memory/journal/on-disk/retention.js +177 -8
  117. package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +9 -0
  118. package/dist/memory/journal/on-disk/workspace-gitignore.js +9 -2
  119. package/dist/memory/journal/self-observation.d.ts +3 -0
  120. package/dist/memory/journal/self-observation.js +67 -0
  121. package/dist/memory/journal/session-end.d.ts +13 -3
  122. package/dist/memory/journal/session-end.js +24 -4
  123. package/dist/memory/journal/session-journal.d.ts +115 -5
  124. package/dist/memory/journal/session-journal.js +479 -32
  125. package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
  126. package/dist/memory/project/artifact-root-resolver.js +89 -0
  127. package/dist/memory/project/artifact-root.d.ts +39 -2
  128. package/dist/memory/project/artifact-root.js +58 -1
  129. package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
  130. package/dist/memory/project/dir/fill-value-store.js +88 -0
  131. package/dist/memory/project/dir/reticle-dir.d.ts +14 -4
  132. package/dist/memory/project/dir/reticle-dir.js +11 -0
  133. package/dist/memory/project/fs/fs-port.d.ts +22 -1
  134. package/dist/memory/project/fs/fs-port.js +33 -5
  135. package/dist/memory/project/learned-routes.d.ts +16 -2
  136. package/dist/memory/project/learned-routes.js +26 -10
  137. package/dist/memory/project/project-for-root.d.ts +30 -0
  138. package/dist/memory/project/project-for-root.js +43 -0
  139. package/dist/memory/project/project-tools.js +10 -5
  140. package/dist/memory/project/session-root.d.ts +35 -6
  141. package/dist/memory/project/session-root.js +70 -8
  142. package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
  143. package/dist/portal/bridge/auth-failure-reason.js +15 -1
  144. package/dist/portal/bridge/bridge.d.ts +6 -1
  145. package/dist/portal/bridge/bridge.js +36 -2
  146. package/dist/portal/session/facts/session-replaced.d.ts +19 -0
  147. package/dist/portal/session/facts/session-replaced.js +22 -0
  148. package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
  149. package/dist/portal/session/no-session-diagnosis.js +67 -60
  150. package/dist/portal/session/no-session-next-action.js +3 -2
  151. package/dist/portal/session/no-session-watch.js +14 -10
  152. package/dist/portal/session/rebind.d.ts +18 -0
  153. package/dist/portal/session/rebind.js +38 -0
  154. package/dist/portal/session/session-manager.d.ts +4 -0
  155. package/dist/portal/session/session-manager.js +19 -1
  156. package/dist/portal/session/session.d.ts +21 -10
  157. package/dist/portal/session/session.js +29 -42
  158. package/dist/status-payload.d.ts +12 -1
  159. package/dist/status-payload.js +4 -1
  160. package/dist/surface/http-server.js +3 -5
  161. package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
  162. package/dist/surface/mcp/faults/mcp-outage.js +20 -4
  163. package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
  164. package/dist/surface/mcp/mcp-proxy.js +20 -9
  165. package/dist/surface/mcp/mcp.d.ts +10 -1
  166. package/dist/surface/mcp/mcp.js +59 -29
  167. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
  168. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
  169. package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
  170. package/dist/surface/mcp/proxy/proxy-log.js +7 -22
  171. package/dist/surface/tools/act/act-preflight.js +6 -1
  172. package/dist/surface/tools/act/sequence-grade.js +13 -0
  173. package/dist/surface/tools/act-capsule.js +27 -1
  174. package/dist/surface/tools/assert/assert-verdict.js +44 -17
  175. package/dist/surface/tools/coverage-tools.js +5 -2
  176. package/dist/surface/tools/dynamic-tools.js +37 -46
  177. package/dist/surface/tools/error-recovery.d.ts +6 -0
  178. package/dist/surface/tools/error-recovery.js +15 -0
  179. package/dist/surface/tools/explore-tools.js +80 -7
  180. package/dist/surface/tools/feedback-tools.js +49 -2
  181. package/dist/surface/tools/first-sentence.d.ts +24 -0
  182. package/dist/surface/tools/first-sentence.js +0 -0
  183. package/dist/surface/tools/harness-explore.d.ts +125 -1
  184. package/dist/surface/tools/harness-explore.js +401 -15
  185. package/dist/surface/tools/harness-plan.d.ts +76 -0
  186. package/dist/surface/tools/harness-plan.js +156 -0
  187. package/dist/surface/tools/harness-toolset.js +12 -1
  188. package/dist/surface/tools/invoke-tool.js +65 -5
  189. package/dist/surface/tools/live-call-text.d.ts +6 -0
  190. package/dist/surface/tools/live-call-text.js +36 -2
  191. package/dist/surface/tools/observe-tools.js +55 -9
  192. package/dist/surface/tools/read/query-windowed.d.ts +1 -1
  193. package/dist/surface/tools/read/query-windowed.js +1 -1
  194. package/dist/surface/tools/read-tools.js +1 -2
  195. package/dist/surface/tools/tool-kit.d.ts +18 -3
  196. package/dist/surface/tools/tools-helpers.d.ts +8 -7
  197. package/dist/surface/tools/tools-helpers.js +11 -13
  198. package/dist/surface/tools/tools.js +6 -1
  199. package/dist/telemetry/bug-found.js +32 -2
  200. package/dist/telemetry/feedback-context.js +3 -3
  201. package/dist/telemetry/harness-drive.d.ts +6 -0
  202. package/dist/telemetry/harness-drive.js +39 -0
  203. package/dist/telemetry/mcp-connection.d.ts +14 -1
  204. package/dist/telemetry/mcp-connection.js +17 -2
  205. package/dist/telemetry/onboarding-firsts.d.ts +30 -1
  206. package/dist/telemetry/onboarding-firsts.js +54 -12
  207. package/dist/telemetry/verification-of.js +31 -3
  208. package/docs/agent-cheatsheet.md +7 -3
  209. package/docs/autodrive.md +42 -2
  210. package/docs/benchmarks.md +1 -1
  211. package/docs/capabilities.mdx +1 -1
  212. package/docs/cli/cloud.mdx +3 -3
  213. package/docs/cli/doctor.mdx +1 -0
  214. package/docs/cli/status.mdx +35 -20
  215. package/docs/cli.mdx +8 -18
  216. package/docs/desktop.mdx +1 -1
  217. package/docs/faq.mdx +1 -1
  218. package/docs/install-manual.mdx +1 -1
  219. package/docs/packages/tauri.mdx +1 -1
  220. package/docs/packages.mdx +1 -1
  221. package/docs/predicates.mdx +16 -0
  222. package/docs/system-map.md +6 -4
  223. package/docs/telemetry.md +2 -1
  224. package/docs/token-efficiency.md +2 -2
  225. package/docs/tools/overview.mdx +1 -1
  226. package/docs/troubleshooting.mdx +8 -2
  227. package/docs/usage.md +1 -1
  228. package/package.json +6 -6
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Starting, stopping and restarting the daemon — the three commands that decide whether a `reticle`
3
+ * process exists, and the waiting each of them has to do to answer honestly.
4
+ *
5
+ * Lifted out of `cli.ts` for the reason `cli-launch.ts` was before it: that file sat at exactly the
6
+ * thousand-line cap, so the next line added to it — to any command, related or not — failed the
7
+ * build. This is the largest group in it that is one idea rather than several.
8
+ *
9
+ * The idea is: a lifecycle command must report what is actually the case, and the only way to know
10
+ * that is to wait and look. `serve` waits for the daemon to ANSWER before claiming it started,
11
+ * because the child binds asynchronously and a parent that reports the spawn reports a guess.
12
+ * `stop` waits for the process to be gone and escalates when it is not, because a daemon that
13
+ * ignores SIGTERM is the one situation `stop` exists for. `restart` reports its two halves
14
+ * separately, because "killed nothing" and "killed it but could not start a new one" leave the user
15
+ * in different places and one exit code cannot say which.
16
+ */
17
+ import { log } from '../../../log.js';
18
+ import { RETICLE_VERIFY_DEFAULT_PORT } from '../../../index.js';
19
+ import { verifyEndpointMismatch } from '../../../status-payload.js';
20
+ import { probeDaemon } from '../../../surface/mcp/mcp-proxy.js';
21
+ import { stateDirProblem } from '../../daemon/state-dir.js';
22
+ import { readPid, isAlive, removePid, spawnDaemon, logPath, reticleStateHome, } from '../../daemon/daemon.js';
23
+ import { readDaemonStartupCause } from '../../daemon/lifetime/startup-failure.js';
24
+ import { PortPresence, probePresence, describePresence, } from '../../daemon/binding/port-presence.js';
25
+ import { daemonSpawnArgs } from '../daemon-start-options.js';
26
+ import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
27
+ import { runKill } from '../cli-kill.js';
28
+ export function handleServe(parsed) {
29
+ void serveWithHonestExit(parsed);
30
+ }
31
+ /**
32
+ * Report the BIND, not the spawn.
33
+ *
34
+ * `serve` used to log `reticle_daemon_spawned` and exit 0 unconditionally, while the child died at
35
+ * `reticle_daemon_start_failed` with an EADDRINUSE nothing joined back to the parent. Three surfaces
36
+ * then disagreed about what was true and none of them named the port.
37
+ *
38
+ * Two changes, both about saying what is actually the case: refuse up front when the port is held by
39
+ * something that is not a Reticle daemon (a spawn there cannot succeed), and after spawning, wait
40
+ * for the daemon to actually answer before claiming it started.
41
+ */
42
+ async function serveWithHonestExit(parsed) {
43
+ const presence = await probePresence(parsed.port, {
44
+ tcpOpen: probeDaemon,
45
+ status: fetchStatus,
46
+ });
47
+ if (presence === PortPresence.DAEMON) {
48
+ // A daemon that is already up was started with ITS flags, not these — `serve` only attaches.
49
+ // Exiting 0 here regardless is how `--http-port` came to be accepted and ignored (#687): the
50
+ // running daemon kept serving whatever it was started with, and the flag vanished without a
51
+ // word. Ask the daemon which verify port it actually serves and refuse when it is not the one
52
+ // requested — a flag that cannot be honoured must say so, not report success.
53
+ if (parsed.http) {
54
+ const mismatch = verifyEndpointMismatch(await fetchStatus(parsed.port), parsed.httpPort ?? RETICLE_VERIFY_DEFAULT_PORT);
55
+ if (mismatch !== undefined) {
56
+ log('reticle_daemon_start_refused', { port: parsed.port, reason: mismatch });
57
+ process.stderr.write(`${mismatch}\n`);
58
+ process.exit(1);
59
+ return;
60
+ }
61
+ }
62
+ log('reticle_daemon_already_running', { port: parsed.port });
63
+ return;
64
+ }
65
+ if (presence === PortPresence.FOREIGN) {
66
+ log('reticle_daemon_start_refused', {
67
+ port: parsed.port,
68
+ presence,
69
+ reason: describePresence(presence, parsed.port),
70
+ });
71
+ process.stderr.write(`${describePresence(presence, parsed.port)}\n`);
72
+ process.exit(1);
73
+ return;
74
+ }
75
+ const scriptPath = process.argv[1];
76
+ if (scriptPath === undefined) {
77
+ log('reticle_serve_no_script', {});
78
+ process.exit(1);
79
+ return;
80
+ }
81
+ const daemonArgs = daemonSpawnArgs(parsed);
82
+ const startupStartedAt = Date.now();
83
+ spawnDaemon(process.execPath, scriptPath, daemonArgs, parsed.port);
84
+ // The child binds asynchronously and, when it cannot, exits 1 long after this process would have
85
+ // reported success. Wait for it to ANSWER — `/status` responding is the only evidence a daemon
86
+ // exists that does not come from the pid file the child may never have earned.
87
+ const bound = await waitForPresence(parsed.port, PortPresence.DAEMON, SERVE_BIND_TIMEOUT_MS);
88
+ if (!bound) {
89
+ const settled = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
90
+ const startupCause = readDaemonStartupCause(logPath(parsed.port), startupStartedAt);
91
+ log('reticle_daemon_start_failed_parent', {
92
+ port: parsed.port,
93
+ presence: settled,
94
+ reason: describePresence(settled, parsed.port),
95
+ log: logPath(parsed.port),
96
+ stateDir: stateDirProblem(reticleStateHome()) === undefined ? 'writable' : 'unwritable',
97
+ ...(startupCause === undefined ? {} : { cause: startupCause }),
98
+ });
99
+ // Name the CAUSE when we can see it. An unwritable state directory is a first-run failure mode
100
+ // (locked-down home, read-only container mount, managed profile) that produced only "nothing is
101
+ // listening" — the symptom restated — and then pointed at a log INSIDE that directory, which
102
+ // cannot exist. Reproduced with `chmod 555 ~/.reticle`.
103
+ const dirProblem = stateDirProblem(reticleStateHome());
104
+ process.stderr.write(dirProblem === undefined
105
+ ? startupCause === undefined
106
+ ? `the daemon did not come up on :${String(parsed.port)} — ${describePresence(settled, parsed.port)}\n` +
107
+ `see ${logPath(parsed.port)}\n`
108
+ : `the daemon did not come up on :${String(parsed.port)}.\n${startupCause}\n` +
109
+ `see ${logPath(parsed.port)}\n`
110
+ : `the daemon did not come up on :${String(parsed.port)}.\n${dirProblem}\n`);
111
+ process.exit(1);
112
+ return;
113
+ }
114
+ log('reticle_daemon_spawned', { port: parsed.port, ...(parsed.http ? { http: true } : {}) });
115
+ }
116
+ /** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
117
+ const SERVE_BIND_TIMEOUT_MS = 15_000;
118
+ const PRESENCE_POLL_MS = 150;
119
+ /** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
120
+ const GRACEFUL_STOP_MS = 5000;
121
+ /** How long after SIGKILL before we accept the process is not ours to kill and say so. */
122
+ const FORCED_STOP_MS = 2000;
123
+ async function waitForPresence(port, want, timeoutMs) {
124
+ const deadline = Date.now() + timeoutMs;
125
+ for (;;) {
126
+ const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
127
+ if (presence === want)
128
+ return true;
129
+ if (Date.now() >= deadline)
130
+ return false;
131
+ await new Promise((resolve) => setTimeout(resolve, PRESENCE_POLL_MS));
132
+ }
133
+ }
134
+ export function handleStop(port, quiet) {
135
+ const pid = readPid(port);
136
+ if (null === pid || !isAlive(pid)) {
137
+ removePid(port);
138
+ if (!quiet)
139
+ log('reticle_daemon_not_running', { port });
140
+ return;
141
+ }
142
+ process.kill(pid, 'SIGTERM');
143
+ const started = Date.now();
144
+ let escalated = false;
145
+ const poll = setInterval(() => {
146
+ if (!isAlive(pid)) {
147
+ clearInterval(poll);
148
+ removePid(port);
149
+ if (!quiet)
150
+ log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
151
+ return;
152
+ }
153
+ const waited = Date.now() - started;
154
+ // A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
155
+ // up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
156
+ // first two minutes of setup. Escalate instead: graceful first, then unconditional.
157
+ if (!escalated && waited > GRACEFUL_STOP_MS) {
158
+ escalated = true;
159
+ try {
160
+ process.kill(pid, 'SIGKILL');
161
+ }
162
+ catch {
163
+ // Already gone between the liveness check and here — the next tick reports it stopped.
164
+ }
165
+ return;
166
+ }
167
+ if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
168
+ clearInterval(poll);
169
+ // Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
170
+ // zombie. Neither is retryable, and both need the pid named so a human can act on it.
171
+ if (!quiet)
172
+ log('reticle_daemon_stop_timeout', { port, pid, escalated });
173
+ process.exit(1);
174
+ }
175
+ }, 100);
176
+ }
177
+ /**
178
+ * `reticle restart` — free the port, then put a daemon back on it and prove it bound.
179
+ *
180
+ * The two halves are reported separately on purpose. A restart that killed the old daemon and then
181
+ * could not start a new one leaves the user in a different place from one that could not kill
182
+ * anything at all, and a single `ok: false` cannot tell them which happened.
183
+ */
184
+ export async function handleRestart(port, force) {
185
+ const freed = await runKill(port, force);
186
+ if (!freed) {
187
+ log('reticle_restart_aborted', {
188
+ port,
189
+ reason: 'the port was not freed, so nothing was started — the old holder is still there',
190
+ });
191
+ process.exit(1);
192
+ return;
193
+ }
194
+ // Reuses `serve`'s path, which waits for the daemon to ANSWER before claiming it started and exits
195
+ // non-zero when it does not. A restart that reports success for a daemon that never bound is the
196
+ // same lie `serve` already stopped telling.
197
+ await serveWithHonestExit({ port, headless: true, http: false });
198
+ }
@@ -17,7 +17,7 @@ import { resolveMcpPort, daemonProjectAt } from '../daemon/daemon-resolve.js';
17
17
  import { daemonSpawnArgs } from './daemon-start-options.js';
18
18
  import { WakeAction, decideWake } from '../daemon/wake-decision.js';
19
19
  import { pickDaemonPortToBind } from '../daemon/binding/free-port.js';
20
- import { fetchStatus } from './launch/cli-launch.js';
20
+ import { fetchStatus } from '../daemon/binding/daemon-status-probe.js';
21
21
  import { migrateApprovals } from '../setup/approval-migration.js';
22
22
  import { ReticleEnv } from '@reticlehq/core';
23
23
  import { agentIo } from '../setup/agent-io.js';
@@ -80,7 +80,7 @@ export async function handleMcp(opts) {
80
80
  // swallows stderr, so wiring these to `log` meant a crash was handled and then unrecorded — the
81
81
  // failure a user reports as "it disconnected" left nothing behind to read.
82
82
  setProxyLogPort(port);
83
- installProxyResilience(process, proxyLog);
83
+ const resilience = installProxyResilience(process, proxyLog);
84
84
  /**
85
85
  * Make sure a daemon is on the port, spawning one if not.
86
86
  *
@@ -163,7 +163,9 @@ export async function handleMcp(opts) {
163
163
  // crash. The proxy itself is unaffected — it has already installed its stdin reader and goes
164
164
  // on serving from cache, waking a daemon on the next request — which is exactly why this
165
165
  // must be logged as the expected condition it is rather than reported as a defect.
166
- void startMcpProxy(port, ensure).catch((err) => {
166
+ void startMcpProxy(port, ensure, undefined, () => {
167
+ resilience.noteUsableSession();
168
+ }).catch((err) => {
167
169
  log('reticle_mcp_proxy_first_connect_failed', {
168
170
  port,
169
171
  error: err instanceof Error ? err.message : String(err),
@@ -7,6 +7,7 @@
7
7
  * 3..reticle.json "port" field in the cwd ← per-project isolation
8
8
  * 4. RETICLE_DEFAULT_PORT (4400)
9
9
  */
10
+ import { type ProjectId } from '@reticlehq/core';
10
11
  /** The project config `reticle init` writes. */
11
12
  export declare const RETICLE_CONFIG_BASENAME = ".reticle.json";
12
13
  /**
@@ -39,7 +40,7 @@ export declare function readProjectPort(cwd: string): number | undefined;
39
40
  * uses it as the default resolve scope so auto-selection stays within the active app. Returns
40
41
  * undefined if the file is absent/unreadable or has no non-empty string projectId.
41
42
  */
42
- export declare function readProjectId(cwd: string): string | undefined;
43
+ export declare function readProjectId(cwd: string): ProjectId | undefined;
43
44
  /**
44
45
  * The framework `reticle init` stamped into `.reticle.json`, when there is one.
45
46
  *
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { dirname, resolve } from 'node:path';
12
+ import { asProjectId } from '@reticlehq/core';
12
13
  /** The project config `reticle init` writes. */
13
14
  export const RETICLE_CONFIG_BASENAME = '.reticle.json';
14
15
  /**
@@ -76,8 +77,11 @@ export function readProjectPort(cwd) {
76
77
  */
77
78
  export function readProjectId(cwd) {
78
79
  const id = findProjectConfig(cwd)?.['projectId'];
80
+ // MINTED HERE. The other provenance boundary: the `projectId` key of a project's own
81
+ // `.reticle.json`. Like HELLO's field, it is a project's id because of WHERE it was read, not
82
+ // because of what it looks like.
79
83
  if ('string' === typeof id && id.length > 0)
80
- return id;
84
+ return asProjectId(id);
81
85
  return undefined;
82
86
  }
83
87
  /**
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `reticle status`, rendered for a person.
3
+ *
4
+ * The command printed one line of JSON and nothing else. Its own source called it "the second-most
5
+ * run command and the one a HUMAN types" — and the answer to that human was
6
+ * `{"t":"…","event":"reticle_status","port":4400,"running":true,…}`. It is worse than that now that
7
+ * agents run it: an agent's terminal output is read by the person sitting behind it, so a machine
8
+ * line is a line two readers have to decode and only one of them can.
9
+ *
10
+ * The JSON stays, unchanged, on stderr — it is documented, and it is what a log is for. This is the
11
+ * same facts on stdout, in the shape `doctor` already uses, so the two commands read as one tool.
12
+ *
13
+ * Pure, and tolerant: it is handed the very object the log line is built from, narrows it without
14
+ * trusting it, and prints what it can recognise. A status that cannot be rendered must still be
15
+ * logged, so nothing here may throw.
16
+ */
17
+ /** Every row label this block can print. The one spelling; nothing writes a label inline. */
18
+ export declare const StatusLabel: {
19
+ readonly DAEMON: "daemon";
20
+ readonly SESSIONS: "sessions";
21
+ readonly AGENT_LINK: "agent link";
22
+ readonly NEXT: "next";
23
+ readonly SPLIT_BRAIN: "split brain";
24
+ readonly UPDATE: "update";
25
+ };
26
+ export type StatusLabel = (typeof StatusLabel)[keyof typeof StatusLabel];
27
+ /** The block to print on stdout, one string per line, heading first. */
28
+ export declare function statusLines(fields: Record<string, unknown>): string[];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `reticle status`, rendered for a person.
3
+ *
4
+ * The command printed one line of JSON and nothing else. Its own source called it "the second-most
5
+ * run command and the one a HUMAN types" — and the answer to that human was
6
+ * `{"t":"…","event":"reticle_status","port":4400,"running":true,…}`. It is worse than that now that
7
+ * agents run it: an agent's terminal output is read by the person sitting behind it, so a machine
8
+ * line is a line two readers have to decode and only one of them can.
9
+ *
10
+ * The JSON stays, unchanged, on stderr — it is documented, and it is what a log is for. This is the
11
+ * same facts on stdout, in the shape `doctor` already uses, so the two commands read as one tool.
12
+ *
13
+ * Pure, and tolerant: it is handed the very object the log line is built from, narrows it without
14
+ * trusting it, and prints what it can recognise. A status that cannot be rendered must still be
15
+ * logged, so nothing here may throw.
16
+ */
17
+ /** Every row label this block can print. The one spelling; nothing writes a label inline. */
18
+ export const StatusLabel = {
19
+ DAEMON: 'daemon',
20
+ SESSIONS: 'sessions',
21
+ AGENT_LINK: 'agent link',
22
+ NEXT: 'next',
23
+ SPLIT_BRAIN: 'split brain',
24
+ UPDATE: 'update',
25
+ };
26
+ const LABELS = Object.values(StatusLabel);
27
+ /** The heading, so a reader can tell which command produced the block. */
28
+ const HEADING = 'reticle status';
29
+ const INDENT = ' ';
30
+ /** Padded off the longest label, for the same reason `doctor` does it: the column IS the legibility. */
31
+ const LABEL_COLUMN = LABELS.reduce((widest, l) => Math.max(widest, l.length), 0) + 2;
32
+ const OK = '✓';
33
+ const NOT_OK = '✗';
34
+ function row(label, rest) {
35
+ return `${INDENT}${label.padEnd(LABEL_COLUMN)}${rest}`;
36
+ }
37
+ /** A continuation line under a row: indented past the label column, carrying no label of its own. */
38
+ function continuation(rest) {
39
+ return `${INDENT}${' '.repeat(LABEL_COLUMN)}${rest}`;
40
+ }
41
+ function str(fields, key) {
42
+ const v = fields[key];
43
+ return 'string' === typeof v && v.length > 0 ? v : undefined;
44
+ }
45
+ function num(fields, key) {
46
+ const v = fields[key];
47
+ return 'number' === typeof v ? v : undefined;
48
+ }
49
+ function sessionViews(fields) {
50
+ const raw = fields['sessions'];
51
+ if (!Array.isArray(raw))
52
+ return [];
53
+ return raw.flatMap((s) => {
54
+ if ('object' !== typeof s || null === s)
55
+ return [];
56
+ const r = s;
57
+ // Each of these explains something a reader would otherwise call a bug: a throttled or hidden
58
+ // tab answers slowly, a stale one answers about a page that has moved on, and an unresponsive
59
+ // one does not answer at all.
60
+ const notes = [
61
+ true === r['throttled'] ? 'throttled' : undefined,
62
+ true === r['hidden'] ? 'hidden' : undefined,
63
+ true === r['stale'] ? 'stale' : undefined,
64
+ true === r['unresponsive'] ? 'unresponsive' : undefined,
65
+ ].filter((n) => n !== undefined);
66
+ return [{ url: str(r, 'url') ?? '(no url reported)', notes }];
67
+ });
68
+ }
69
+ /** The block to print on stdout, one string per line, heading first. */
70
+ export function statusLines(fields) {
71
+ const port = num(fields, 'port');
72
+ const at = port === undefined ? '' : ` on :${String(port)}`;
73
+ const pid = num(fields, 'pid');
74
+ const running = true === fields['running'];
75
+ const lines = [HEADING];
76
+ lines.push(row(StatusLabel.DAEMON, running
77
+ ? `${OK} running${at}${pid === undefined ? '' : ` (pid ${String(pid)})`}`
78
+ : `${NOT_OK} not running${at}`));
79
+ // What was actually ON the port, when the pid file said nothing useful. "A stranger holds this"
80
+ // and "nothing is here" were the same output once, and the true sentence was said by nothing a
81
+ // person sees — so the word belongs in the prose, not only in the event.
82
+ const presence = str(fields, 'presence');
83
+ if (presence !== undefined)
84
+ lines.push(continuation(`port presence: ${presence}`));
85
+ // The refusal a FOREIGN port produces. It is the whole answer when it is present, so it rides the
86
+ // daemon row rather than waiting for a `next` that is not built in that branch.
87
+ const reason = str(fields, 'reason');
88
+ if (reason !== undefined)
89
+ lines.push(continuation(reason));
90
+ if (running) {
91
+ const count = num(fields, 'sessionCount') ?? 0;
92
+ lines.push(row(StatusLabel.SESSIONS, count > 0
93
+ ? `${OK} ${String(count)} page${1 === count ? '' : 's'} connected`
94
+ : `${NOT_OK} no page connected`));
95
+ for (const s of sessionViews(fields)) {
96
+ lines.push(continuation(`${s.url}${0 === s.notes.length ? '' : ` (${s.notes.join(', ')})`}`));
97
+ }
98
+ // The daemon's own explanation for an empty session list, which outranks anything this command
99
+ // could infer — it is the one holding the connection.
100
+ const why = str(fields, 'why');
101
+ if (why !== undefined)
102
+ lines.push(continuation(why));
103
+ }
104
+ const client = str(fields, 'mcpClient');
105
+ if (client !== undefined)
106
+ lines.push(row(StatusLabel.AGENT_LINK, client));
107
+ for (const [label, key] of [
108
+ [StatusLabel.NEXT, 'nextAction'],
109
+ [StatusLabel.SPLIT_BRAIN, 'splitBrain'],
110
+ [StatusLabel.UPDATE, 'updateAvailable'],
111
+ ]) {
112
+ const value = str(fields, key);
113
+ if (value !== undefined)
114
+ lines.push(row(label, value));
115
+ }
116
+ return lines;
117
+ }
@@ -2,5 +2,5 @@
2
2
  import { parseCliArgs, CLI_USAGE } from './cli/cli-parse.js';
3
3
  export { parseCliArgs, CLI_USAGE };
4
4
  export type { CliResult } from './cli/cli-parse.js';
5
- export declare function handleStatus(port: number): Promise<void>;
5
+ export declare function handleStatus(port: number, json?: boolean): Promise<void>;
6
6
  export declare function main(): void;