@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,43 @@
1
+ /**
2
+ * The project memory store for ONE artifact root.
3
+ *
4
+ * `deps.project` is bound to the daemon's own root at construction, which is right only when the
5
+ * daemon happens to have been started inside the app it is driving — and a user-scoped MCP
6
+ * registration, the common case, starts it wherever the editor's cwd is. So every run record, every
7
+ * learned route and every observability high-water mark was written into that directory: a
8
+ * `.reticle/` appearing in a backend nobody instrumented, and a driven project whose own memory
9
+ * stayed empty. The cloud half of `recordReplayRun` had already been moved onto the session's root
10
+ * and its local half left behind, which is how a replay outcome could reach the dashboard and not
11
+ * the project it belonged to.
12
+ *
13
+ * Reads and writes both go through here, deliberately: a write that resolves to the app while a
14
+ * read still answers from the daemon's directory is worse than either alone — the record lands and
15
+ * is then invisible to the tool that exists to report it.
16
+ *
17
+ * Rebinding is cheap (a filesystem port, a root and a clock) and the single-project case allocates
18
+ * nothing: when the resolved root IS the daemon's, `deps.project` comes back unchanged.
19
+ */
20
+ import { ProjectStore } from './project-store.js';
21
+ export function projectForRoot(deps, root) {
22
+ if (root === deps.reticleRoot)
23
+ return deps.project;
24
+ return new ProjectStore(deps.fs, root, { now: deps.now });
25
+ }
26
+ /**
27
+ * The same question asked by the daemon rather than by a tool: one store per artifact root, so a
28
+ * session's learned routes land in the app that was driven and not in whatever directory the daemon
29
+ * was launched from. The daemon's own store is seeded in, so the single-project case allocates
30
+ * nothing and keeps the instance every other caller already holds.
31
+ */
32
+ export function projectStoreResolver(fs, daemonStore, daemonRoot, now) {
33
+ const byRoot = new Map([[daemonRoot, daemonStore]]);
34
+ return (root) => {
35
+ const key = root ?? daemonRoot;
36
+ let found = byRoot.get(key);
37
+ if (found === undefined) {
38
+ found = new ProjectStore(fs, key, { now });
39
+ byRoot.set(key, found);
40
+ }
41
+ return found;
42
+ };
43
+ }
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ import { sessionRoot } from './session-root.js';
3
+ import { projectForRoot } from './project-for-root.js';
2
4
  import { ProjectReadError, RunStatus } from '@reticlehq/core';
3
5
  import { ReticleTool } from '@reticlehq/core';
4
6
  import { countSchema } from '../../surface/tools/args/numeric-bounds.js';
@@ -67,9 +69,11 @@ function lastTwoFor(runs, name) {
67
69
  * The per-flow diff between the two most-recent verification ARTIFACTS (.reticle/runs), or undefined when
68
70
  * fewer than two exist. Never throws — a missing/unreadable artifact must not break reading run history.
69
71
  */
70
- async function lastTwoRunArtifacts(deps) {
72
+ async function lastTwoRunArtifacts(deps,
73
+ /** The session's root — the same address `persistAndSyncVerificationRun` wrote the pair to. */
74
+ root) {
71
75
  try {
72
- const pair = await new RunStore(deps.fs, deps.reticleRoot).latestTwo();
76
+ const pair = await new RunStore(deps.fs, root).latestTwo();
73
77
  return pair === undefined ? undefined : diffVerificationRuns(pair[0], pair[1]);
74
78
  }
75
79
  catch {
@@ -106,7 +110,8 @@ export const PROJECT_TOOLS = [
106
110
  handler: async (deps, args) => {
107
111
  const cloud = await cloudRegression(deps, asString(args['sessionId']));
108
112
  const withCloud = (obj) => cloud === undefined ? obj : { ...obj, cloud };
109
- const read = await deps.project.read();
113
+ const project = projectForRoot(deps, sessionRoot(deps, asString(args['sessionId'])));
114
+ const read = await project.read();
110
115
  if (!read.ok) {
111
116
  return withCloud({
112
117
  error: read.reason === ProjectReadError.MISSING
@@ -133,14 +138,14 @@ export const PROJECT_TOOLS = [
133
138
  learned: read.file.learned,
134
139
  });
135
140
  }
136
- const lastRun = await deps.project.lastRun(name);
141
+ const lastRun = await project.lastRun(name);
137
142
  const pair = lastTwoFor(read.file.runs, name);
138
143
  //: the RICH run diff (per-flow duration deltas past a noise floor, status changes, new/removed
139
144
  // flows, verdict change) over the last two verification ARTIFACTS. That lives alongside — not
140
145
  // instead of — the lightweight RunRecord diff above: `diff` answers "did this named run behave like
141
146
  // last time?" from project.json, while `runDiff` answers "what changed between the last two full
142
147
  // verification runs?" from .reticle/runs. Best-effort: no artifacts simply means no runDiff.
143
- const runDiff = await lastTwoRunArtifacts(deps);
148
+ const runDiff = await lastTwoRunArtifacts(deps, sessionRoot(deps, asString(args['sessionId'])));
144
149
  const forName = read.file.runs.filter((r) => r.name === name);
145
150
  return withCloud({
146
151
  runs: recent(forName),
@@ -1,3 +1,4 @@
1
+ import type { ProjectId } from '@reticlehq/core';
1
2
  import type { ToolDeps } from '../../surface/tools/tool-kit.js';
2
3
  /**
3
4
  * The `.reticle` directory a tool call's artifacts belong in.
@@ -9,15 +10,43 @@ import type { ToolDeps } from '../../surface/tools/tool-kit.js';
9
10
  * fixed — the flow would save successfully and then be invisible to the tool that exists to replay
10
11
  * it, which reads as "no flows covered this change" rather than as an error.
11
12
  *
12
- * Falls back to `deps.reticleRoot` whenever resolution cannot name a project, which is exactly the
13
- * behaviour every call site had before the resolver existed.
13
+ * Falls back to `deps.reticleRoot` whenever resolution cannot name a project — EXCEPT when the
14
+ * caller named a session that resolves to nothing, which is the one case where the fallback is a
15
+ * wrong answer rather than a cautious one. See `sessionProjectId`.
14
16
  */
15
17
  export declare function sessionRoot(deps: ToolDeps, sessionId: string | undefined): string;
18
+ /**
19
+ * The same answer for a caller that already holds the projectId rather than a sessionId.
20
+ *
21
+ * The suite paths resolve the project ONCE and thread it down -- the run artifact, the cloud link,
22
+ * the flake ledger and the flows all belong to that one project -- so asking them to go back to the
23
+ * session manager for an id they already have is how a caller ends up taking the root from one
24
+ * place and the id from another. `sessionRoot` is now this function plus a lookup, so the two
25
+ * cannot disagree about what a resolved root is.
26
+ */
27
+ export declare function rootForProjectId(deps: ToolDeps, projectId: ProjectId | undefined): string;
28
+ /**
29
+ * The PROJECT directory -- one level above `.reticle` -- for a caller that runs a tool in a tree
30
+ * rather than writing a file into one. `git diff` is the case: it was being run in the daemon's
31
+ * `.reticle` subdirectory, which answers about the wrong repository (or, from a globally-registered
32
+ * daemon at `/` or `$HOME`, about none) while reporting a clean "nothing changed".
33
+ */
34
+ export declare function projectDirFor(deps: ToolDeps, sessionId: string | undefined): string;
16
35
  /**
17
36
  * The connected session's projectId, or undefined when there is no session to ask.
18
37
  *
19
- * `sessions.resolve` throws when nothing is connected, when the id names no session, and when
20
- * several are connected and none was named. All three mean the same thing here — we cannot tell
21
- * which project this call is about — and none of them is a reason to fail the caller's tool.
38
+ * `sessions.resolve` throws for three reasons, and they do not all mean the same thing. Nothing
39
+ * connected, and several connected with none named, both mean "we cannot tell which project this
40
+ * call is about" — on a call that named no session that is not a reason to fail the caller's tool,
41
+ * which may not need a session at all, so it degrades to the daemon's own root.
42
+ *
43
+ * A sessionId that names NO session is the third, and it is a different fact. An agent that passes
44
+ * an id has named the project too, so swallowing that throw does not degrade to a cautious answer:
45
+ * it silently retargets the call at whichever checkout the daemon was started in (#994).
46
+ * The same fallback can expose another project's ledger on reads as well as misdirect writes.
47
+ *
48
+ * So the ambiguous case still declines (the resolver has its own `AMBIGUOUS` branch for the same
49
+ * reason) and the mis-addressed case refuses. There is no root that is right for a dead id, so
50
+ * there is no answer either.
22
51
  */
23
- export declare function sessionProjectId(deps: ToolDeps, sessionId: string | undefined): string | undefined;
52
+ export declare function sessionProjectId(deps: ToolDeps, sessionId: string | undefined): ProjectId | undefined;
@@ -1,3 +1,4 @@
1
+ import { dirname } from 'node:path';
1
2
  /**
2
3
  * The `.reticle` directory a tool call's artifacts belong in.
3
4
  *
@@ -8,18 +9,62 @@
8
9
  * fixed — the flow would save successfully and then be invisible to the tool that exists to replay
9
10
  * it, which reads as "no flows covered this change" rather than as an error.
10
11
  *
11
- * Falls back to `deps.reticleRoot` whenever resolution cannot name a project, which is exactly the
12
- * behaviour every call site had before the resolver existed.
12
+ * Falls back to `deps.reticleRoot` whenever resolution cannot name a project — EXCEPT when the
13
+ * caller named a session that resolves to nothing, which is the one case where the fallback is a
14
+ * wrong answer rather than a cautious one. See `sessionProjectId`.
13
15
  */
14
16
  export function sessionRoot(deps, sessionId) {
15
- return deps.artifactRootFor?.(sessionProjectId(deps, sessionId)).root ?? deps.reticleRoot;
17
+ // Skip the LOOKUP, never the refusal.
18
+ //
19
+ // `artifactRootFor?.(…)` short-circuits its own argument, so an unwired caller used to skip
20
+ // resolution entirely and hand a named-but-dead session the daemon's own directory without ever
21
+ // checking the id -- which is how one app's intents, flows and runs land in another app's
22
+ // checkout. Resolving as an ARGUMENT fixes that, because arguments are evaluated first.
23
+ //
24
+ // But `resolve(undefined)` is not free: it builds the ranked no-session diagnosis and records
25
+ // which branch it took. With no resolver wired AND no id named there is nothing to refuse and
26
+ // nothing its answer could change, so that one case skips the lookup and costs what it always
27
+ // did. Every embedder of this engine, and every older construction of ToolDeps, is on it.
28
+ const nothingToRefuse = deps.artifactRootFor === undefined && sessionId === undefined;
29
+ return rootForProjectId(deps, nothingToRefuse ? undefined : sessionProjectId(deps, sessionId));
30
+ }
31
+ /**
32
+ * The same answer for a caller that already holds the projectId rather than a sessionId.
33
+ *
34
+ * The suite paths resolve the project ONCE and thread it down -- the run artifact, the cloud link,
35
+ * the flake ledger and the flows all belong to that one project -- so asking them to go back to the
36
+ * session manager for an id they already have is how a caller ends up taking the root from one
37
+ * place and the id from another. `sessionRoot` is now this function plus a lookup, so the two
38
+ * cannot disagree about what a resolved root is.
39
+ */
40
+ export function rootForProjectId(deps, projectId) {
41
+ return deps.artifactRootFor?.(projectId).root ?? deps.reticleRoot;
42
+ }
43
+ /**
44
+ * The PROJECT directory -- one level above `.reticle` -- for a caller that runs a tool in a tree
45
+ * rather than writing a file into one. `git diff` is the case: it was being run in the daemon's
46
+ * `.reticle` subdirectory, which answers about the wrong repository (or, from a globally-registered
47
+ * daemon at `/` or `$HOME`, about none) while reporting a clean "nothing changed".
48
+ */
49
+ export function projectDirFor(deps, sessionId) {
50
+ return dirname(sessionRoot(deps, sessionId));
16
51
  }
17
52
  /**
18
53
  * The connected session's projectId, or undefined when there is no session to ask.
19
54
  *
20
- * `sessions.resolve` throws when nothing is connected, when the id names no session, and when
21
- * several are connected and none was named. All three mean the same thing here — we cannot tell
22
- * which project this call is about — and none of them is a reason to fail the caller's tool.
55
+ * `sessions.resolve` throws for three reasons, and they do not all mean the same thing. Nothing
56
+ * connected, and several connected with none named, both mean "we cannot tell which project this
57
+ * call is about" — on a call that named no session that is not a reason to fail the caller's tool,
58
+ * which may not need a session at all, so it degrades to the daemon's own root.
59
+ *
60
+ * A sessionId that names NO session is the third, and it is a different fact. An agent that passes
61
+ * an id has named the project too, so swallowing that throw does not degrade to a cautious answer:
62
+ * it silently retargets the call at whichever checkout the daemon was started in (#994).
63
+ * The same fallback can expose another project's ledger on reads as well as misdirect writes.
64
+ *
65
+ * So the ambiguous case still declines (the resolver has its own `AMBIGUOUS` branch for the same
66
+ * reason) and the mis-addressed case refuses. There is no root that is right for a dead id, so
67
+ * there is no answer either.
23
68
  */
24
69
  /*
25
70
  * Exported alongside `sessionRoot`, and that is the point rather than a convenience. A caller that
@@ -32,7 +77,24 @@ export function sessionProjectId(deps, sessionId) {
32
77
  try {
33
78
  return deps.sessions.resolve(sessionId).projectId;
34
79
  }
35
- catch {
36
- return undefined;
80
+ catch (cause) {
81
+ if (sessionId === undefined)
82
+ return undefined;
83
+ throw unresolvedTargetRefusal(sessionId, cause);
37
84
  }
38
85
  }
86
+ /**
87
+ * Why a named-but-dead session gets no directory, and what to do instead.
88
+ *
89
+ * The manager's own message is carried verbatim at the end because it is the actionable half — it
90
+ * names the sessions that ARE connected, with their ids and urls, or says that none are. Rebuilding
91
+ * a worse version of that here is how the two no-session paths drifted apart the last time.
92
+ */
93
+ function unresolvedTargetRefusal(sessionId, cause) {
94
+ const because = cause instanceof Error ? cause.message : String(cause);
95
+ return new Error(`sessionId '${sessionId}' names no session, so there is no project to resolve this call's ` +
96
+ '.reticle directory against, and nothing was read or written. Reticle refuses rather than ' +
97
+ "falling back to the daemon's own directory, which belongs to whatever project the daemon " +
98
+ "was started in — that is how one app's intents, flows and runs land in another app's " +
99
+ `checkout. Omit sessionId to use the connected app, or name one that is live. ${because}`);
100
+ }
@@ -20,4 +20,13 @@
20
20
  * With no evidence — a fresh daemon, or a HELLO naming no project — nothing is inferred and the
21
21
  * plain reason stands, unless the page sent no token.
22
22
  */
23
+ /**
24
+ * Whether a recorded close is a pairing-token refusal.
25
+ *
26
+ * The bridge records the specific sentence it sent on the socket, not one fixed string, so a
27
+ * comparison with a single constant misses a wrong token, a missing token, and a daemon that
28
+ * belongs to another project. All three are refusals. A later good session is a separate question
29
+ * the caller answers with `connectedSinceLastClosure`.
30
+ */
31
+ export declare function isAuthRefusalReason(reason: string | undefined): boolean;
23
32
  export declare function authFailureReason(servedProjects: ReadonlySet<string>, helloProject: string | undefined, helloToken?: string): string;
@@ -32,6 +32,20 @@ const WRONG_TOKEN = 'authentication failed: wrong pairing token — run `reticle
32
32
  const NO_TOKEN = 'authentication failed: no pairing token on the page';
33
33
  /** WebSocket close reasons are capped at 123 bytes; a longer one throws and closes with nothing. */
34
34
  const MAX_REASON_BYTES = 123;
35
+ const DIFFERENT_PROJECT_PREFIX = 'this daemon serves a different project';
36
+ /**
37
+ * Whether a recorded close is a pairing-token refusal.
38
+ *
39
+ * The bridge records the specific sentence it sent on the socket, not one fixed string, so a
40
+ * comparison with a single constant misses a wrong token, a missing token, and a daemon that
41
+ * belongs to another project. All three are refusals. A later good session is a separate question
42
+ * the caller answers with `connectedSinceLastClosure`.
43
+ */
44
+ export function isAuthRefusalReason(reason) {
45
+ if (reason === undefined)
46
+ return false;
47
+ return reason.startsWith('authentication failed') || reason.startsWith(DIFFERENT_PROJECT_PREFIX);
48
+ }
35
49
  export function authFailureReason(servedProjects, helloProject, helloToken) {
36
50
  // ONE served project, not merely some. A daemon that has accepted sessions from SEVERAL projects
37
51
  // is demonstrably not owned by any one of them, so "that daemon is not yours, stop it" is the
@@ -45,7 +59,7 @@ export function authFailureReason(servedProjects, helloProject, helloToken) {
45
59
  if (helloProject !== undefined &&
46
60
  1 === servedProjects.size &&
47
61
  !servedProjects.has(helloProject)) {
48
- const reason = `this daemon serves a different project — run \`reticle stop\` and retry`;
62
+ const reason = `${DIFFERENT_PROJECT_PREFIX} — run \`reticle stop\` and retry`;
49
63
  return Buffer.byteLength(reason, 'utf8') <= MAX_REASON_BYTES ? reason : PLAIN;
50
64
  }
51
65
  if (helloToken === undefined || 0 === helloToken.length)
@@ -20,7 +20,7 @@ export declare const WS_CLOSE_REASON: {
20
20
  * this stays as the close-code table's label and as the fallback when the skew cannot be read.
21
21
  */
22
22
  readonly PROTOCOL_MISMATCH: "protocol version mismatch — @reticlehq/browser and server disagree";
23
- readonly AUTH_FAILED: "authentication failed — reload the page to pick up the current pairing token";
23
+ readonly AUTH_FAILED: "authentication failed — restart the dev server; a reload keeps the old pairing token";
24
24
  /**
25
25
  * The daemon threw while handling a message. Closing is deliberate: an exception leaves the
26
26
  * session in a state neither side can describe, and a socket left OPEN and unresponsive is the
@@ -97,6 +97,11 @@ export declare class Bridge {
97
97
  * is to make something happen sooner than it already would.
98
98
  */
99
99
  attachSyncRequest(handler: () => void): void;
100
+ /**
101
+ * Register a handler for the panel's harness switch. Optional for the same reason as the above: a
102
+ * bridge built without one ignores the request rather than refusing it.
103
+ */
104
+ attachHarnessRequest(handler: (enabled: boolean) => void): void;
100
105
  /** Register a handler to run when a session connects. Additive — every handler runs. */
101
106
  attachSessionCreate(handler: (session: Session) => void): void;
102
107
  close(): Promise<void>;
@@ -48,7 +48,7 @@ export const WS_CLOSE_REASON = {
48
48
  * this stays as the close-code table's label and as the fallback when the skew cannot be read.
49
49
  */
50
50
  PROTOCOL_MISMATCH: 'protocol version mismatch — @reticlehq/browser and server disagree',
51
- AUTH_FAILED: 'authentication failed — reload the page to pick up the current pairing token',
51
+ AUTH_FAILED: 'authentication failed — restart the dev server; a reload keeps the old pairing token',
52
52
  /**
53
53
  * The daemon threw while handling a message. Closing is deliberate: an exception leaves the
54
54
  * session in a state neither side can describe, and a socket left OPEN and unresponsive is the
@@ -119,6 +119,24 @@ function replayRequest(event) {
119
119
  const name = event.data['text'];
120
120
  return 'string' === typeof name && name.length > 0 ? name : undefined;
121
121
  }
122
+ /**
123
+ * The desired harness state if this event is the panel's switch, else undefined.
124
+ *
125
+ * Carries `on`/`off` rather than meaning "toggle": a double-click on a flaky connection sends two,
126
+ * and two toggles land back where they started while two `on`s are the same as one.
127
+ */
128
+ function harnessRequest(event) {
129
+ if (event.type !== EventType.HUMAN_CONTROL)
130
+ return undefined;
131
+ if (event.data['kind'] !== HumanControlKind.HARNESS)
132
+ return undefined;
133
+ const want = event.data['text'];
134
+ if ('on' === want)
135
+ return true;
136
+ if ('off' === want)
137
+ return false;
138
+ return undefined;
139
+ }
122
140
  /** True when this event is the panel's "sync now" button. Pure boundary narrowing, like the above. */
123
141
  function isSyncRequest(event) {
124
142
  return event.type === EventType.HUMAN_CONTROL && event.data['kind'] === HumanControlKind.SYNC;
@@ -225,6 +243,7 @@ export class Bridge {
225
243
  #onSessionEnd;
226
244
  /** Wired by the daemon: push to the dashboard now, because somebody asked in the panel. */
227
245
  #onSyncRequest;
246
+ #onHarnessRequest;
228
247
  constructor(options) {
229
248
  const host = options.host ?? LOOPBACK_HOST;
230
249
  if ((options.token?.length ?? 0) > TRANSPORT_LIMITS.MAX_TOKEN_LENGTH) {
@@ -479,7 +498,7 @@ export class Bridge {
479
498
  presented: parsed.token !== undefined && 0 < parsed.token.length,
480
499
  tokenSource: this.#tokenSource,
481
500
  });
482
- this.sessions.noteClosure(WS_CLOSE_REASON.AUTH_FAILED, this.#clock());
501
+ this.sessions.noteClosure(reason, this.#clock());
483
502
  socket.close(WS_CLOSE.AUTH_FAILED[0], reason);
484
503
  return;
485
504
  }
@@ -616,6 +635,10 @@ export class Bridge {
616
635
  // A panel ▶ replay needs the daemon's flow store, which the Session can't reach — route it to
617
636
  // the daemon-wired handler instead of the in-session control path. Everything else is normal.
618
637
  const replay = replayRequest(parsed.event);
638
+ // Narrowed before the chain, not inside it: the result is needed by the branch AND by the
639
+ // test that decides whether this is a panel control at all, and calling it twice would
640
+ // invite the two calls to disagree.
641
+ const harness = harnessRequest(parsed.event);
619
642
  if (replay !== undefined)
620
643
  this.#onReplay?.(session.id, replay);
621
644
  // Same shape as ▶ replay, and routed the same way: what to push lives in the daemon's
@@ -623,6 +646,10 @@ export class Bridge {
623
646
  // us to hurry, not asking for a result.
624
647
  else if (isSyncRequest(parsed.event))
625
648
  this.#onSyncRequest?.();
649
+ // The switch is written through to the platform by the daemon, for the same reason: the
650
+ // Session has no cloud credential and should not grow one.
651
+ else if (harness !== undefined)
652
+ this.#onHarnessRequest?.(harness);
626
653
  // Pass the raw frame's byte length so the buffer doesn't re-serialize every event for accounting.
627
654
  else
628
655
  session.pushEvent(parsed.event, Buffer.byteLength(text, 'utf8'));
@@ -758,6 +785,13 @@ export class Bridge {
758
785
  attachSyncRequest(handler) {
759
786
  this.#onSyncRequest = handler;
760
787
  }
788
+ /**
789
+ * Register a handler for the panel's harness switch. Optional for the same reason as the above: a
790
+ * bridge built without one ignores the request rather than refusing it.
791
+ */
792
+ attachHarnessRequest(handler) {
793
+ this.#onHarnessRequest = handler;
794
+ }
761
795
  /** Register a handler to run when a session connects. Additive — every handler runs. */
762
796
  attachSessionCreate(handler) {
763
797
  this.#onSessionCreate.push(handler);
@@ -22,3 +22,22 @@ export declare function sessionReplacedReason(id: string, url: string): string;
22
22
  * without each one re-deriving a message — an `Error`, a bare string, or something else entirely.
23
23
  */
24
24
  export declare function isSessionReplacedError(error: unknown): boolean;
25
+ /**
26
+ * The reason every command still in flight is rejected with when the socket under it closes.
27
+ *
28
+ * A full-document navigation closes that socket, so on an MPA — or on any click that loads a new
29
+ * page — this arrives for the exact command that CAUSED it, and the successor is still connecting.
30
+ * `SessionManager.remove` cannot tell those two apart at the moment it runs: it is called from the
31
+ * socket's own close handler, the tombstone it writes is what a successor is later matched against,
32
+ * and nothing has arrived yet. So the rejection stays a plain transport failure, as
33
+ * `PendingCommands.rejectAll` documents, and a caller that CAN wait decides what it means.
34
+ */
35
+ export declare const SESSION_DISCONNECTED_REASON = "session disconnected";
36
+ /**
37
+ * Is this failure the page's transport going away, rather than an answer from the page?
38
+ *
39
+ * True for both shapes the same event takes: the reconnect that claimed the id back, and the close
40
+ * that landed before any successor did. A caller holding a budget can follow either; a caller that
41
+ * cannot must say it stopped watching, never that the app failed.
42
+ */
43
+ export declare function isDocumentGoneError(error: unknown): boolean;
@@ -27,3 +27,25 @@ export function isSessionReplacedError(error) {
27
27
  const message = error instanceof Error ? error.message : String(error);
28
28
  return message.includes(SESSION_REPLACED_PREFIX);
29
29
  }
30
+ /**
31
+ * The reason every command still in flight is rejected with when the socket under it closes.
32
+ *
33
+ * A full-document navigation closes that socket, so on an MPA — or on any click that loads a new
34
+ * page — this arrives for the exact command that CAUSED it, and the successor is still connecting.
35
+ * `SessionManager.remove` cannot tell those two apart at the moment it runs: it is called from the
36
+ * socket's own close handler, the tombstone it writes is what a successor is later matched against,
37
+ * and nothing has arrived yet. So the rejection stays a plain transport failure, as
38
+ * `PendingCommands.rejectAll` documents, and a caller that CAN wait decides what it means.
39
+ */
40
+ export const SESSION_DISCONNECTED_REASON = 'session disconnected';
41
+ /**
42
+ * Is this failure the page's transport going away, rather than an answer from the page?
43
+ *
44
+ * True for both shapes the same event takes: the reconnect that claimed the id back, and the close
45
+ * that landed before any successor did. A caller holding a budget can follow either; a caller that
46
+ * cannot must say it stopped watching, never that the app failed.
47
+ */
48
+ export function isDocumentGoneError(error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ return isSessionReplacedError(error) || message === SESSION_DISCONNECTED_REASON;
51
+ }
@@ -165,6 +165,7 @@ export declare const SELF_RECOVERING_MARKER = "Then call reticle_sessions again
165
165
  export declare function explainNoSession(facts: NoSessionFacts): {
166
166
  reason: NoSessionReason;
167
167
  message: string;
168
+ detail?: string;
168
169
  };
169
170
  /**
170
171
  * The message alone, for the callers that render it.