@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,46 @@
1
+ /**
2
+ * The harness driver for OpenAI's Chat Completions API.
3
+ *
4
+ * Third driver, and the reason it exists is comparison rather than capability: people want to know
5
+ * whether the cheap typed-decision model is good enough for THEIR app, and "cheap versus one
6
+ * frontier model" is a weaker answer than "cheap versus the two they might otherwise reach for".
7
+ *
8
+ * A translation layer, exactly like `driver.ts` — the loop lives in `harness.ts` against
9
+ * `ModelDriver` and knows nothing about any wire. Chat Completions is a genuinely different shape
10
+ * from the Messages API, and translating OpenAI into Anthropic's wire on the platform side was
11
+ * considered and rejected there: it would put a second, undeclared model-shaped transformation
12
+ * inside the thing being measured. So this speaks the native format and the arm measures OpenAI.
13
+ *
14
+ * NOT cache-annotated, and that is a real asymmetry to hold in mind when reading a cost comparison
15
+ * against the Anthropic arm. OpenAI caches long prefixes automatically and reports it back in
16
+ * `usage.prompt_tokens_details.cached_tokens`, so the saving is not something this driver arranges;
17
+ * it is reported below so the number is visible rather than assumed.
18
+ */
19
+ import type { ModelDriver } from './harness.js';
20
+ import type { HarnessFetch } from './driver.js';
21
+ /**
22
+ * The default model.
23
+ *
24
+ * Deliberately not the largest available, for the same reason `DEFAULT_HARNESS_MODEL` is not: the
25
+ * driver is choosing what to touch next, and the engine decides the verdict from evidence. A driver
26
+ * that needed a frontier model would be evidence the engine was not doing its job.
27
+ */
28
+ export declare const DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
29
+ export declare const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com";
30
+ export interface OpenAiDriverOptions {
31
+ apiKey: string;
32
+ model?: string;
33
+ baseUrl?: string;
34
+ /** Injected for tests. Defaults to the platform `fetch`. */
35
+ fetch?: HarnessFetch;
36
+ }
37
+ /**
38
+ * Read this driver's configuration out of the environment.
39
+ *
40
+ * A real OpenAI key goes to OpenAI. The platform key goes to the platform, which forwards with its
41
+ * own — so somebody who has run `reticle link` can drive with this model having never held an
42
+ * OpenAI key, which is the entire point of the proxy.
43
+ */
44
+ export declare function openAiOptionsFromEnv(env: Record<string, string | undefined>): OpenAiDriverOptions | undefined;
45
+ /** Build a driver backed by Chat Completions. */
46
+ export declare function openAiDriver(options: OpenAiDriverOptions): ModelDriver;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The harness driver for OpenAI's Chat Completions API.
3
+ *
4
+ * Third driver, and the reason it exists is comparison rather than capability: people want to know
5
+ * whether the cheap typed-decision model is good enough for THEIR app, and "cheap versus one
6
+ * frontier model" is a weaker answer than "cheap versus the two they might otherwise reach for".
7
+ *
8
+ * A translation layer, exactly like `driver.ts` — the loop lives in `harness.ts` against
9
+ * `ModelDriver` and knows nothing about any wire. Chat Completions is a genuinely different shape
10
+ * from the Messages API, and translating OpenAI into Anthropic's wire on the platform side was
11
+ * considered and rejected there: it would put a second, undeclared model-shaped transformation
12
+ * inside the thing being measured. So this speaks the native format and the arm measures OpenAI.
13
+ *
14
+ * NOT cache-annotated, and that is a real asymmetry to hold in mind when reading a cost comparison
15
+ * against the Anthropic arm. OpenAI caches long prefixes automatically and reports it back in
16
+ * `usage.prompt_tokens_details.cached_tokens`, so the saving is not something this driver arranges;
17
+ * it is reported below so the number is visible rather than assumed.
18
+ */
19
+ import { z } from 'zod';
20
+ import { ReticleEnv, apiKeyFrom } from '@reticlehq/core';
21
+ /**
22
+ * The default model.
23
+ *
24
+ * Deliberately not the largest available, for the same reason `DEFAULT_HARNESS_MODEL` is not: the
25
+ * driver is choosing what to touch next, and the engine decides the verdict from evidence. A driver
26
+ * that needed a frontier model would be evidence the engine was not doing its job.
27
+ */
28
+ export const DEFAULT_OPENAI_MODEL = 'gpt-5.6-luna';
29
+ export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com';
30
+ /** The platform's own path, which forwards with Reticle's key rather than the caller's. */
31
+ const PLATFORM_PATH = '/v1/model/openai';
32
+ const OPENAI_PATH = '/v1/chat/completions';
33
+ /** Output cap per turn. A driving turn is a sentence and a tool call, never an essay. */
34
+ const MAX_TOKENS = 4096;
35
+ /** How much of one tool result the model is allowed to see. Same reasoning as the Anthropic driver. */
36
+ const MAX_RESULT_CHARS = 8000;
37
+ const ToolCall = z.object({
38
+ id: z.string(),
39
+ function: z.object({ name: z.string(), arguments: z.string().default('{}') }),
40
+ });
41
+ const ChatResponse = z.object({
42
+ choices: z
43
+ .array(z.object({
44
+ message: z.object({
45
+ content: z.string().nullish(),
46
+ tool_calls: z.array(ToolCall).nullish(),
47
+ }),
48
+ }))
49
+ .default([]),
50
+ usage: z
51
+ .object({
52
+ prompt_tokens: z.number().default(0),
53
+ completion_tokens: z.number().default(0),
54
+ prompt_tokens_details: z.object({ cached_tokens: z.number().nullish() }).nullish(),
55
+ })
56
+ .default({ prompt_tokens: 0, completion_tokens: 0 }),
57
+ });
58
+ /**
59
+ * Read this driver's configuration out of the environment.
60
+ *
61
+ * A real OpenAI key goes to OpenAI. The platform key goes to the platform, which forwards with its
62
+ * own — so somebody who has run `reticle link` can drive with this model having never held an
63
+ * OpenAI key, which is the entire point of the proxy.
64
+ */
65
+ export function openAiOptionsFromEnv(env) {
66
+ const model = env[ReticleEnv.HARNESS_OPENAI_MODEL];
67
+ const withModel = (rest) => ({
68
+ ...rest,
69
+ ...(model === undefined || 0 === model.length ? {} : { model }),
70
+ });
71
+ const direct = env[ReticleEnv.HARNESS_OPENAI_KEY];
72
+ if (direct !== undefined && 0 < direct.length)
73
+ return withModel({ apiKey: direct });
74
+ const cloudKey = apiKeyFrom(env);
75
+ const cloudUrl = env[ReticleEnv.CLOUD_URL];
76
+ if (cloudKey === undefined || 0 === cloudKey.length)
77
+ return undefined;
78
+ if (cloudUrl === undefined || 0 === cloudUrl.length)
79
+ return undefined;
80
+ return withModel({ apiKey: cloudKey, baseUrl: cloudUrl });
81
+ }
82
+ function toWireTool(tool) {
83
+ return {
84
+ type: 'function',
85
+ function: { name: tool.name, description: tool.description, parameters: tool.inputSchema },
86
+ };
87
+ }
88
+ function truncate(text) {
89
+ return text.length <= MAX_RESULT_CHARS
90
+ ? text
91
+ : `${text.slice(0, MAX_RESULT_CHARS)}\n…[truncated: ${String(text.length - MAX_RESULT_CHARS)} more characters]`;
92
+ }
93
+ /**
94
+ * The history, in Chat Completions' shape.
95
+ *
96
+ * The one structural difference from the Messages API, and it matters: a tool result is its OWN
97
+ * message carrying `tool_call_id`, where Anthropic puts every result for a turn into one user
98
+ * message. So a turn with three parallel calls becomes one assistant message and three tool
99
+ * messages. That is the format's requirement rather than a choice — an unanswered `tool_call_id`
100
+ * is rejected outright, so every outcome must be emitted, including the failures.
101
+ */
102
+ function toWireMessages(history) {
103
+ const messages = [];
104
+ for (const entry of history) {
105
+ if ('user' === entry.role) {
106
+ messages.push({ role: 'user', content: entry.text });
107
+ continue;
108
+ }
109
+ if ('assistant' === entry.role) {
110
+ messages.push({
111
+ role: 'assistant',
112
+ content: 0 === entry.text.length ? null : entry.text,
113
+ ...(0 === entry.calls.length
114
+ ? {}
115
+ : {
116
+ tool_calls: entry.calls.map((call) => ({
117
+ id: call.id,
118
+ type: 'function',
119
+ function: { name: call.name, arguments: JSON.stringify(call.args) },
120
+ })),
121
+ }),
122
+ });
123
+ continue;
124
+ }
125
+ for (const outcome of entry.outcomes) {
126
+ messages.push({
127
+ role: 'tool',
128
+ tool_call_id: outcome.id,
129
+ content: truncate(JSON.stringify(outcome.result)),
130
+ });
131
+ }
132
+ }
133
+ return messages;
134
+ }
135
+ /** Arguments arrive as a JSON STRING here, not an object — a malformed one is empty, never a throw. */
136
+ function parseArgs(raw) {
137
+ try {
138
+ const parsed = JSON.parse(raw);
139
+ return 'object' === typeof parsed && null !== parsed ? parsed : {};
140
+ }
141
+ catch {
142
+ return {};
143
+ }
144
+ }
145
+ /** Build a driver backed by Chat Completions. */
146
+ export function openAiDriver(options) {
147
+ const model = options.model ?? DEFAULT_OPENAI_MODEL;
148
+ const baseUrl = options.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
149
+ const doFetch = options.fetch ?? ((url, init) => fetch(url, init));
150
+ // The platform does not expose `/v1/chat/completions`; it exposes its own path and forwards. A
151
+ // base URL that is not OpenAI's is the platform's, which is the only other thing this talks to.
152
+ const path = DEFAULT_OPENAI_BASE_URL === baseUrl ? OPENAI_PATH : PLATFORM_PATH;
153
+ return {
154
+ async turn(input) {
155
+ const response = await doFetch(`${baseUrl}${path}`, {
156
+ method: 'POST',
157
+ headers: {
158
+ 'content-type': 'application/json',
159
+ authorization: `Bearer ${options.apiKey}`,
160
+ },
161
+ body: JSON.stringify({
162
+ model,
163
+ max_completion_tokens: MAX_TOKENS,
164
+ tools: input.tools.map(toWireTool),
165
+ messages: [{ role: 'system', content: input.system }, ...toWireMessages(input.history)],
166
+ }),
167
+ });
168
+ const body = await response.text();
169
+ // Thrown, not returned: a model that will not answer is a BROKEN drive, and a broken drive is
170
+ // never reported as clean. Swallowing it would be a verdict about an app nobody drove.
171
+ if (!response.ok)
172
+ throw new Error(`the model refused the request (HTTP ${String(response.status)}): ${body}`);
173
+ const parsed = ChatResponse.parse(JSON.parse(body));
174
+ const message = parsed.choices[0]?.message;
175
+ const calls = (message?.tool_calls ?? []).map((call) => ({
176
+ id: call.id,
177
+ name: call.function.name,
178
+ args: parseArgs(call.function.arguments),
179
+ }));
180
+ const cached = parsed.usage.prompt_tokens_details?.cached_tokens ?? 0;
181
+ return {
182
+ text: message?.content ?? '',
183
+ calls,
184
+ usage: {
185
+ // `prompt_tokens` INCLUDES the cached ones here, unlike the Messages API where the two are
186
+ // disjoint. Subtracting keeps `input` meaning the same thing in both drivers: what was
187
+ // processed at full price. A cost comparison that summed them would charge the arm that
188
+ // cached well for the tokens it did not pay full price for.
189
+ input: Math.max(parsed.usage.prompt_tokens - cached, 0),
190
+ output: parsed.usage.completion_tokens,
191
+ cacheRead: cached,
192
+ cacheWrite: 0,
193
+ },
194
+ };
195
+ },
196
+ };
197
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The driver preference a person set on the platform, read by the daemon that has to honour it.
3
+ *
4
+ * Choosing a model in a web UI is worth nothing if the thing doing the driving never asks. This is
5
+ * the one call that closes that loop: the console writes a per-project preference, and the daemon
6
+ * reads it before it builds a driver.
7
+ *
8
+ * Three rules shape all of it, and they are the difference between a preference and an outage:
9
+ *
10
+ * - it is a PREFERENCE, not a credential. Every failure — no key, no host, a timeout, a 500, a
11
+ * body that does not parse — answers `undefined`, and the caller falls back to what the
12
+ * environment says. A drive must never fail because a settings endpoint was slow.
13
+ * - it is BOUNDED. The fetch is aborted rather than awaited indefinitely. A drive that hangs
14
+ * holds a leased browser context with nothing anywhere saying why, and "the preferences API was
15
+ * unreachable" is the worst possible reason for that to happen.
16
+ * - it never overrides someone who was EXPLICIT. A driver named in the call, or set in the
17
+ * environment, wins over the stored preference. Precedence lives in the caller.
18
+ */
19
+ /** The subset of the platform's answer the harness acts on. */
20
+ export interface PlatformModelConfig {
21
+ /** `jev` | `anthropic` | `openai`, as the platform spells it. Not validated here. */
22
+ provider: string;
23
+ /** Whether the person wants autonomous driving at all. */
24
+ harnessEnabled: boolean;
25
+ /**
26
+ * Whether this workspace may drive on OUR model spend — a claimed free period, or a paid plan.
27
+ *
28
+ * Deliberately a second boolean rather than folded into `harnessEnabled`. One is a switch a person
29
+ * set, the other is a fact about their account, and a daemon that could not tell them apart would
30
+ * report "you turned this off" to somebody whose free months quietly ran out.
31
+ */
32
+ harnessEntitled: boolean;
33
+ }
34
+ /** A GET, narrowed to what this file uses, so a test can answer it without a network. */
35
+ export type ConfigFetch = (url: string, init: {
36
+ method: string;
37
+ headers: Record<string, string>;
38
+ signal?: AbortSignal;
39
+ }) => Promise<{
40
+ ok: boolean;
41
+ status: number;
42
+ text(): Promise<string>;
43
+ }>;
44
+ /**
45
+ * Ask the platform which model this project wants driving with.
46
+ *
47
+ * `undefined` means "no answer", and every caller must treat that as "carry on with the
48
+ * environment" rather than as a failure worth reporting. There is deliberately no error channel:
49
+ * an unreachable settings endpoint is not something the person running a drive can act on, and a
50
+ * warning printed on a path that then works correctly is how people learn to ignore stderr.
51
+ */
52
+ export declare function fetchPlatformConfig(env: Record<string, string | undefined>, doFetch?: ConfigFetch, timeoutMs?: number): Promise<PlatformModelConfig | undefined>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The driver preference a person set on the platform, read by the daemon that has to honour it.
3
+ *
4
+ * Choosing a model in a web UI is worth nothing if the thing doing the driving never asks. This is
5
+ * the one call that closes that loop: the console writes a per-project preference, and the daemon
6
+ * reads it before it builds a driver.
7
+ *
8
+ * Three rules shape all of it, and they are the difference between a preference and an outage:
9
+ *
10
+ * - it is a PREFERENCE, not a credential. Every failure — no key, no host, a timeout, a 500, a
11
+ * body that does not parse — answers `undefined`, and the caller falls back to what the
12
+ * environment says. A drive must never fail because a settings endpoint was slow.
13
+ * - it is BOUNDED. The fetch is aborted rather than awaited indefinitely. A drive that hangs
14
+ * holds a leased browser context with nothing anywhere saying why, and "the preferences API was
15
+ * unreachable" is the worst possible reason for that to happen.
16
+ * - it never overrides someone who was EXPLICIT. A driver named in the call, or set in the
17
+ * environment, wins over the stored preference. Precedence lives in the caller.
18
+ */
19
+ import { ReticleEnv, apiKeyFrom } from '@reticlehq/core';
20
+ const CONFIG_PATH = '/v1/model/config';
21
+ /**
22
+ * How long the daemon will wait for a preference before driving without it.
23
+ *
24
+ * Short on purpose. This runs before a drive that takes tens of seconds, so a second of budget is
25
+ * affordable and a stall is not — and the fallback is not a degraded mode, it is the behaviour
26
+ * everybody had before this endpoint existed.
27
+ */
28
+ const TIMEOUT_MS = 2_000;
29
+ function parse(body) {
30
+ try {
31
+ const parsed = JSON.parse(body);
32
+ if ('object' !== typeof parsed || null === parsed)
33
+ return undefined;
34
+ const record = parsed;
35
+ const provider = record['provider'];
36
+ if ('string' !== typeof provider || 0 === provider.length)
37
+ return undefined;
38
+ // Absent reads as enabled: the platform's own default is on, and a daemon that treated a
39
+ // missing field as "off" would silently disable the feature for anyone on an older API.
40
+ const enabled = record['harnessEnabled'];
41
+ // Same rule for both, for the same reason: an older API that reports neither must not have its
42
+ // silence read as a refusal.
43
+ const entitled = record['harnessEntitled'];
44
+ return {
45
+ provider,
46
+ harnessEnabled: 'boolean' === typeof enabled ? enabled : true,
47
+ harnessEntitled: 'boolean' === typeof entitled ? entitled : true,
48
+ };
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /**
55
+ * Ask the platform which model this project wants driving with.
56
+ *
57
+ * `undefined` means "no answer", and every caller must treat that as "carry on with the
58
+ * environment" rather than as a failure worth reporting. There is deliberately no error channel:
59
+ * an unreachable settings endpoint is not something the person running a drive can act on, and a
60
+ * warning printed on a path that then works correctly is how people learn to ignore stderr.
61
+ */
62
+ export async function fetchPlatformConfig(env, doFetch = (url, init) => fetch(url, init), timeoutMs = TIMEOUT_MS) {
63
+ const key = apiKeyFrom(env);
64
+ const host = env[ReticleEnv.CLOUD_URL];
65
+ if (key === undefined || 0 === key.length)
66
+ return undefined;
67
+ if (host === undefined || 0 === host.length)
68
+ return undefined;
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => {
71
+ controller.abort();
72
+ }, timeoutMs);
73
+ try {
74
+ const res = await doFetch(`${host.replace(/\/+$/, '')}${CONFIG_PATH}`, {
75
+ method: 'GET',
76
+ headers: { authorization: `Bearer ${key}` },
77
+ signal: controller.signal,
78
+ });
79
+ if (!res.ok)
80
+ return undefined;
81
+ return parse(await res.text());
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ finally {
87
+ clearTimeout(timer);
88
+ }
89
+ }
@@ -6,6 +6,21 @@ import { ratioSchema } from '../../surface/tools/args/numeric-bounds.js';
6
6
  import { asNumber, asRecord, asString } from '@reticlehq/core';
7
7
  import { diffPng } from './visual-diff.js';
8
8
  import { VisualStore } from './visual-store.js';
9
+ /**
10
+ * The `.reticle` a capture belongs beside: the driven session's own, stamped when it connected.
11
+ *
12
+ * Falls back to the daemon's root only when there is no session to ask — the same fallback the
13
+ * journal and the run artifact use, and for the same reason: a picture of somebody's app has no
14
+ * business in a directory that has nothing to do with it.
15
+ */
16
+ function captureRoot(deps, sessionId) {
17
+ try {
18
+ return deps.sessions.resolve(sessionId).artifactRoot ?? deps.reticleRoot;
19
+ }
20
+ catch {
21
+ return deps.reticleRoot;
22
+ }
23
+ }
9
24
  import { trackCaptureDirectory } from './capture-cleanup.js';
10
25
  import { readFile, unlink } from 'node:fs/promises';
11
26
  import { tmpdir } from 'node:os';
@@ -253,7 +268,16 @@ export const VISUAL_TOOLS = [
253
268
  return reason === VisualReason.NO_PROVIDER ? noProvider : { ok: false, reason };
254
269
  }
255
270
  const name = asString(args['name']) ?? 'default';
256
- const store = new VisualStore(deps.fs, deps.reticleRoot);
271
+ // Beside the app that was captured. The tool RETURNS this path to the agent, so a wrong root
272
+ // is not only a stray PNG — it is a path the agent is told to look at in a tree that has
273
+ // nothing to do with the screenshot.
274
+ //
275
+ // Taken from the SESSION's own stamp rather than re-resolved, which is what `attach-journal`
276
+ // and the run artifact already do: the capture came from that session, so the answer is
277
+ // already in hand, and a second resolution is a second chance to disagree. It also keeps this
278
+ // directory from reaching into `project` for an address it was deliberately freed from
279
+ // needing — see `directory-reach`.
280
+ const store = new VisualStore(deps.fs, captureRoot(deps, sessionId));
257
281
  // Scoped to the runtime that produced it: an Electron window, a Tauri webview and a browser
258
282
  // tab do not render the same url the same way, and one shared baseline makes every
259
283
  // cross-runtime diff wrong. See visualDir.
@@ -292,7 +316,7 @@ export const VISUAL_TOOLS = [
292
316
  },
293
317
  handler: async (deps, args) => {
294
318
  const baseline = asString(args['baseline']) ?? '';
295
- const store = new VisualStore(deps.fs, deps.reticleRoot);
319
+ const store = new VisualStore(deps.fs, captureRoot(deps, asString(args['sessionId'])));
296
320
  // Capture FIRST, then fetch the baseline for the runtime that produced these pixels. Reading it
297
321
  // by the session's runtime looked equivalent and is not: the route decides the renderer, so a
298
322
  // desktop session captured through a driven browser must be compared against the WEB baseline,
package/dist/index.js CHANGED
@@ -1,12 +1,15 @@
1
+ import { writeHarnessSwitch } from './memory/cloud/harness-switch.js';
2
+ import { harnessConfigSource } from './memory/cloud/harness-config.js';
3
+ import { fetchPlatformConfig } from './features/harness/platform-config.js';
1
4
  import { join } from 'node:path';
2
5
  import { existsSync, readFileSync } from 'node:fs';
3
6
  import { homedir } from 'node:os';
4
- import { resolveProjectCloud } from './memory/cloud/cloud-config.js';
5
- import { startSyncDaemon } from './memory/cloud/sync-daemon.js';
7
+ import { linkedCloudPort } from './memory/cloud/cloud-config.js';
8
+ import { attachCloudSync } from './memory/cloud/sync-daemon.js';
6
9
  import { wireHooks } from './hooks/hook-commands.js';
7
- import { PROJECT_REGISTRY_FILE, emptyProjectRegistry, parseProjectRegistry, projectCandidates, } from '@reticlehq/core/artifacts';
8
- import { discoverProjectConfigs, } from './command/cli/config/config-discovery.js';
9
- import { projectCandidatesFrom, resolveArtifactRoot, } from './memory/project/artifact-root.js';
10
+ import { PROJECT_REGISTRY_FILE, parseProjectRegistry, projectCandidates, } from '@reticlehq/core/artifacts';
11
+ import { discoverProjectConfigs } from './command/cli/config/config-discovery.js';
12
+ import { artifactRootResolver } from './memory/project/artifact-root-resolver.js';
10
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
14
  import { AGENT_STOPPED_NOTICE, RETICLE_DEFAULT_PORT, ReticleCommand, ReticleDir, ReticleEnv, LOOPBACK_HOST, ReplayStatus, EventType, } from '@reticlehq/core';
12
15
  import { originOf } from './portal/session/session-manager.js';
@@ -25,6 +28,7 @@ import { initImpact } from './memory/impact/impact-recorder.js';
25
28
  import { FlowStore } from './language/flows/flows.js';
26
29
  import { buildFlowChips } from './language/flows/flow-scope.js';
27
30
  import { ProjectStore } from './memory/project/project-store.js';
31
+ import { projectStoreResolver } from './memory/project/project-for-root.js';
28
32
  import { attachRouteLearning } from './memory/project/learned-routes.js';
29
33
  import { AnnotationStore } from './language/flows/stores/annotation-store.js';
30
34
  import { createNodeFileSystem } from './memory/project/fs/fs-port.js';
@@ -169,7 +173,15 @@ function attachJournal(bridge, deps) {
169
173
  // Stamp the project's own `.reticle` before ANY counter fires for this session. Without it every
170
174
  // verdict is recorded against wherever the daemon was started, which is how one app's evidence
171
175
  // reached a different account's production dashboard.
172
- session.artifactRoot = resolveArtifactRoot(session.projectId).root;
176
+ // The origin is passed for the case where the page never stamped a project id: it is the only
177
+ // distinguishing fact left, and without it every such app shares one bucket.
178
+ session.artifactRoot = resolveArtifactRoot(session.projectId, originOf(session.url)).root;
179
+ // Here rather than on the start path, which was neither the moment we were about to write into a
180
+ // repository nor the root we were about to write into: it created `.reticle/` — holding nothing
181
+ // but the ignore file — wherever the daemon was launched, coming back every boot after the user
182
+ // deleted it, while the journals this ignore protects landed in another tree, uncovered.
183
+ if (deps.enabled)
184
+ void ensureWorkspaceGitignore(deps.fs, session.artifactRoot);
173
185
  journalAttach(session);
174
186
  // Seed the learned ambient map so a fresh session starts knowing which regions churn, instead of
175
187
  // re-learning from zero. Best-effort + async: a late seed still helps, a failure is silent.
@@ -181,7 +193,12 @@ function attachJournal(bridge, deps) {
181
193
  }
182
194
  });
183
195
  // Teardown: flush the journal tail to disk + persist what this session learned.
184
- bridge.attachSessionEnd(makeSessionEnd(deps));
196
+ bridge.attachSessionEnd(makeSessionEnd({
197
+ ...deps,
198
+ // Retention runs from teardown, and it must not delete the journal of a session that is still
199
+ // being written. The registry is the only thing that knows which those are.
200
+ liveSessionIds: () => new Set(bridge.sessions.all().map((s) => s.id)),
201
+ }));
185
202
  /*
186
203
  * And the same write, DURING the session rather than only at the end of it.
187
204
  *
@@ -201,16 +218,15 @@ function attachJournal(bridge, deps) {
201
218
  });
202
219
  }
203
220
  if (deps.enabled) {
204
- void pruneSessions(deps.fs, deps.reticleRoot);
221
+ // Empty in the ordinary case (nothing has connected yet), but this path also runs on a daemon
222
+ // that is already serving sessions.
223
+ void pruneSessions(deps.fs, deps.reticleRoot, {
224
+ live: new Set(bridge.sessions.all().map((s) => s.id)),
225
+ });
205
226
  // The largest thing in the workspace, and until now the only one with no delete path at all.
206
227
  void pruneVisualDiffs(deps.fs, deps.reticleRoot);
207
228
  // Write-only local copies of reports the outbox already carries.
208
229
  void pruneFeedback(deps.fs, deps.reticleRoot);
209
- // Here rather than in `init`, because this is the moment we are actually about to write into
210
- // somebody's repository — and the paths that reach it without ever running `init` (a plugin
211
- // install, a hand-added client config) are exactly the ones that would otherwise leave an
212
- // unexplained pile of untracked files behind. Best-effort and write-once; see the helper.
213
- void ensureWorkspaceGitignore(deps.fs, deps.reticleRoot);
214
230
  }
215
231
  }
216
232
  /**
@@ -309,39 +325,18 @@ function knownProjectRoots() {
309
325
  }
310
326
  return [...roots];
311
327
  }
312
- function artifactRootResolver(daemonRoot) {
313
- return (projectId) => {
314
- let registry = emptyProjectRegistry();
315
- try {
316
- const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
317
- registry = existsSync(path)
318
- ? parseProjectRegistry(JSON.parse(readFileSync(path, 'utf8')))
319
- : registry;
320
- }
321
- catch {
322
- // A cache that cannot be read is an empty cache, never an error: the daemon still resolves
323
- // through discovery, and falls back to its own root exactly as it did before this existed.
324
- }
325
- let discovery = { found: [], searched: [] };
326
- try {
327
- discovery = discoverProjectConfigs(process.cwd());
328
- }
329
- catch {
330
- // Same reasoning: a diagnostic search that throws must not take a tool call with it.
331
- }
332
- return resolveArtifactRoot({
333
- projectId,
334
- candidates: projectCandidatesFrom(discovery, registry),
335
- daemonRoot,
336
- });
337
- };
338
- }
339
328
  export async function start(options = {}) {
340
329
  const port = options.port ?? RETICLE_DEFAULT_PORT;
341
330
  // Open the user's impact record before anything can connect. Not inside the MCP branch: a daemon
342
331
  // serving a browser with no agent attached still has a HUD to answer, and a report that reads
343
332
  // "nothing recorded yet" over a month of history on disk is the worst version of this feature.
344
- initImpact({ reticleRoot: options.reticleRoot ?? join(process.cwd(), ReticleDir.ROOT) });
333
+ initImpact({
334
+ reticleRoot: options.reticleRoot ?? join(process.cwd(), ReticleDir.ROOT),
335
+ // The daemon owns both sides of this seam, so it is the layer that may join them: the cache
336
+ // lives in cloud memory, the platform read lives in the harness feature, and neither is allowed
337
+ // to reach for the other.
338
+ config: harnessConfigSource(() => fetchPlatformConfig(process.env)),
339
+ });
345
340
  const uninstallHooks = wireHooks(options.reticleRoot ?? join(process.cwd(), ReticleDir.ROOT), readProjectId(process.cwd()));
346
341
  const security = await resolveBridgeSecurityWithAutoToken(options);
347
342
  const bridge = new Bridge({ port, sdkFix: sdkFixForCwd, ...security });
@@ -381,7 +376,7 @@ export async function start(options = {}) {
381
376
  flows,
382
377
  });
383
378
  const project = new ProjectStore(fs, reticleRoot, { now });
384
- attachRouteLearning(bridge, project);
379
+ attachRouteLearning(bridge, projectStoreResolver(fs, project, reticleRoot, now));
385
380
  const annotations = new AnnotationStore();
386
381
  pool = createBrowserPool(options.headless ?? true);
387
382
  leaseReaper = new LeaseReaper(pool);
@@ -462,7 +457,13 @@ export async function startDaemon(options = {}) {
462
457
  // was never opened in the process the HUD talks to: tool calls still recorded (the dispatch
463
458
  // chokepoint opens it lazily), but a tab that connected before the first tool call was pushed
464
459
  // nothing, so the report read "nothing recorded yet" over a file with history in it.
465
- initImpact({ reticleRoot: options.reticleRoot ?? join(process.cwd(), ReticleDir.ROOT) });
460
+ initImpact({
461
+ reticleRoot: options.reticleRoot ?? join(process.cwd(), ReticleDir.ROOT),
462
+ // The daemon owns both sides of this seam, so it is the layer that may join them: the cache
463
+ // lives in cloud memory, the platform read lives in the harness feature, and neither is allowed
464
+ // to reach for the other.
465
+ config: harnessConfigSource(() => fetchPlatformConfig(process.env)),
466
+ });
466
467
  const security = await resolveBridgeSecurityWithAutoToken(options);
467
468
  const shared = createSharedServer(security.token === undefined ? {} : { token: security.token });
468
469
  const bridge = new Bridge({ port, server: shared.httpServer, sdkFix: sdkFixForCwd, ...security });
@@ -477,7 +478,7 @@ export async function startDaemon(options = {}) {
477
478
  // answers WHY rather than just "sessionCount: 0".
478
479
  // `verifyPort` rides along so a later `serve --http` can tell whether this daemon already honours
479
480
  // the requested `--http-port` instead of silently ignoring the flag (#687).
480
- shared.attachStatus(() => statusPayload(bridge.sessions.count(), bridge.sessions.list(), bridge.sessions.noSessionHint(), verifyHttp?.port));
481
+ shared.attachStatus(() => statusPayload(bridge.sessions.count(), bridge.sessions.list(), bridge.sessions.noSessionHint(), verifyHttp?.port, bridge.sessions.noSessionLead()));
481
482
  // Agent-independent presence: the daemon outlives any single agent, so when the LAST agent's MCP
482
483
  // connection drops (it stopped, or is waiting on the human), end every session and push a clear
483
484
  // "go to your terminal" notice to the panel — the human is on the browser and must not lose a typed
@@ -504,7 +505,7 @@ export async function startDaemon(options = {}) {
504
505
  /*
505
506
  * Bound AFTER the sync daemon exists, read only when a run is actually written.
506
507
  *
507
- * `attachJournal` runs before `startSyncDaemon` because the journal has to be capturing before
508
+ * `attachJournal` runs before `attachCloudSync` because the journal has to be capturing before
508
509
  * anything can connect, and reordering them so this could be a direct reference would put sync
509
510
  * setup ahead of session capture for the sake of one callback.
510
511
  */
@@ -519,7 +520,7 @@ export async function startDaemon(options = {}) {
519
520
  onRunPersisted: () => syncNudge.run?.(),
520
521
  });
521
522
  const project = new ProjectStore(fs, reticleRoot, { now });
522
- attachRouteLearning(bridge, project);
523
+ attachRouteLearning(bridge, projectStoreResolver(fs, project, reticleRoot, now));
523
524
  const annotations = new AnnotationStore();
524
525
  const pool = createBrowserPool(options.headless ?? true);
525
526
  const leaseReaper = new LeaseReaper(pool);
@@ -540,19 +541,22 @@ export async function startDaemon(options = {}) {
540
541
  * events a user most wants: the ones at the start of a run.
541
542
  */
542
543
  wireHooks(reticleRoot, readProjectId(process.cwd()));
543
- const cloudSync = startSyncDaemon({
544
+ const cloudSync = attachCloudSync({
545
+ fs,
544
546
  reticleRoot,
545
- cloud: () => resolveProjectCloud(fs, reticleRoot, homedir(), process.env),
546
- // Every OTHER linked repo on this machine, not just the directory the daemon was started in.
547
- // One daemon serves many projects, and pushing only its own root left the rest silently
548
- // reporting nothing — indistinguishable, on the dashboard, from nobody having verified anything.
549
- otherRoots: () => Promise.resolve(knownProjectRoots()),
550
- cloudFor: (root) => resolveProjectCloud(fs, root, homedir(), process.env),
547
+ homeDir: homedir(),
548
+ env: process.env,
549
+ otherRoots: knownProjectRoots,
551
550
  });
552
551
  syncNudge.run = () => cloudSync.nudge();
553
- // The panel's sync button. `syncNow`, not `nudge`: a nudge schedules a cycle soon, which is right
554
- // for "a run landed" and wrong for a button somebody is watching. Never awaited.
552
+ // `syncNow`, not `nudge`: a nudge schedules a cycle soon, which is right for "a run landed" and
553
+ // wrong for a button somebody is watching. Never awaited.
555
554
  bridge.attachSyncRequest(() => void cloudSync.syncNow());
555
+ // The panel's harness switch. Written through to the platform rather than kept locally, so the
556
+ // console and the panel cannot disagree about a setting they both offer. Nothing is awaited and a
557
+ // failure is not surfaced: the next snapshot re-reads the platform, so a lost write shows up as
558
+ // the switch springing back, which is the truthful outcome.
559
+ bridge.attachHarnessRequest((enabled) => void writeHarnessSwitch(process.env, enabled));
556
560
  // Scope auto-selection to the active project (from .reticle.json) so a stray tab from another app is
557
561
  // never picked when the agent omits a sessionId. Explicit per-call scope/sessionId still overrides.
558
562
  // Scope + the no-session diagnosis: "no browser session connected" is the error that ends most
@@ -574,6 +578,7 @@ export async function startDaemon(options = {}) {
574
578
  project,
575
579
  fs,
576
580
  reticleRoot,
581
+ linkedCloud: linkedCloudPort(fs, reticleRoot, homedir(), process.env),
577
582
  // The long-lived daemon needs this MORE than the standalone MCP process does, not less: it is
578
583
  // the one that outlives a single project and serves every app on the machine. Omitting it here
579
584
  // silently disabled per-session artifact resolution for every agent that attaches to a running