@reticlehq/server 3.4.0 → 3.5.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 (69) hide show
  1. package/dist/command/cli/auth/cloud-session.d.ts +4 -4
  2. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  3. package/dist/command/cli/cli-parse.d.ts +1 -1
  4. package/dist/command/cli/cli-parse.js +3 -1
  5. package/dist/command/cli/cloud-cli.js +59 -0
  6. package/dist/command/cli/cloud-login.d.ts +1 -1
  7. package/dist/command/cli/cloud-login.js +8 -7
  8. package/dist/command/cli/setup-mcp-cli.js +9 -1
  9. package/dist/command/cli.js +17 -1
  10. package/dist/command/setup/bringup/dev-server-wait.d.ts +2 -0
  11. package/dist/command/setup/bringup/dev-server-wait.js +1 -1
  12. package/dist/command/setup/init/init-runtime.js +53 -8
  13. package/dist/command/setup/node-effects.js +6 -1
  14. package/dist/command/setup/run-setup.d.ts +2 -0
  15. package/dist/command/setup/run-setup.js +7 -2
  16. package/dist/command/setup/setup-mcp.d.ts +2 -0
  17. package/dist/command/setup/setup-mcp.js +14 -5
  18. package/dist/command/version/peer-announce.d.ts +6 -2
  19. package/dist/command/version/peer-announce.js +8 -6
  20. package/dist/command/version/version-nudge.d.ts +17 -3
  21. package/dist/command/version/version-nudge.js +25 -4
  22. package/dist/features/harness/fill-values.d.ts +4 -0
  23. package/dist/features/harness/fill-values.js +47 -11
  24. package/dist/features/harness/openai-driver.d.ts +12 -0
  25. package/dist/features/harness/openai-driver.js +17 -5
  26. package/dist/features/license/license.d.ts +2 -2
  27. package/dist/index.js +17 -9
  28. package/dist/judgement/capsule/capsule-store.d.ts +4 -4
  29. package/dist/memory/cloud/sync-cycle.d.ts +9 -0
  30. package/dist/memory/cloud/sync-cycle.js +29 -2
  31. package/dist/memory/intent/intent-tools.js +8 -3
  32. package/dist/memory/journal/envelope.d.ts +8 -8
  33. package/dist/memory/journal/journal-query.d.ts +14 -0
  34. package/dist/memory/journal/journal-query.js +15 -0
  35. package/dist/memory/project/artifact-root-resolver.d.ts +10 -0
  36. package/dist/memory/project/artifact-root-resolver.js +46 -19
  37. package/dist/portal/bridge/bridge.d.ts +4 -3
  38. package/dist/portal/bridge/bridge.js +6 -1
  39. package/dist/portal/input/viewport-tools.js +16 -6
  40. package/dist/portal/session/session.d.ts +5 -8
  41. package/dist/portal/session/session.js +9 -13
  42. package/dist/surface/http-server.d.ts +1 -1
  43. package/dist/surface/http-server.js +4 -3
  44. package/dist/surface/mcp/mcp.js +1 -1
  45. package/dist/surface/mcp/proxy/proxy-log.js +3 -5
  46. package/dist/surface/tools/act-tools.js +2 -2
  47. package/dist/surface/tools/dynamic-tools.js +29 -2
  48. package/dist/surface/tools/error-recovery.js +1 -2
  49. package/dist/surface/tools/harness-explore.js +5 -1
  50. package/dist/surface/tools/invoke-tool.js +12 -8
  51. package/dist/surface/tools/tool-kit.d.ts +10 -0
  52. package/dist/telemetry/argument-shape.js +1 -1
  53. package/dist/telemetry/onboarding-firsts.d.ts +2 -2
  54. package/dist/telemetry/onboarding-firsts.js +2 -2
  55. package/docs/actions.mdx +1 -2
  56. package/docs/cli/cloud.mdx +6 -4
  57. package/docs/cli.mdx +13 -7
  58. package/docs/desktop.mdx +1 -1
  59. package/docs/getting-started.md +12 -2
  60. package/docs/install-agentic.mdx +1 -1
  61. package/docs/install-manual.mdx +1 -1
  62. package/docs/packages/next.mdx +3 -0
  63. package/docs/packages/tauri.mdx +1 -1
  64. package/docs/packages/vite-plugin.mdx +2 -0
  65. package/docs/packages.mdx +1 -1
  66. package/docs/quickstart.mdx +11 -9
  67. package/docs/tools/act.mdx +1 -1
  68. package/docs/usage.md +0 -1
  69. package/package.json +5 -5
@@ -34,30 +34,57 @@ function daemonSitsInAProject(daemonRoot) {
34
34
  return false;
35
35
  }
36
36
  }
37
+ /**
38
+ * Every project this machine knows the directory of: discovered `.reticle.json` files first, then
39
+ * the user-level registry. Read on each call, since `init` can run in another terminal while the
40
+ * daemon is up.
41
+ */
42
+ function knownProjectCandidates() {
43
+ let registry = emptyProjectRegistry();
44
+ try {
45
+ const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
46
+ registry = existsSync(path)
47
+ ? parseProjectRegistry(JSON.parse(readFileSync(path, 'utf8')))
48
+ : registry;
49
+ }
50
+ catch {
51
+ // A cache that cannot be read is an empty cache, never an error: the daemon still resolves
52
+ // through discovery, and falls back to its own root exactly as it did before this existed.
53
+ }
54
+ let discovery = { found: [], searched: [] };
55
+ try {
56
+ discovery = discoverProjectConfigs(process.cwd());
57
+ }
58
+ catch {
59
+ // Same reasoning: a diagnostic search that throws must not take a tool call with it.
60
+ }
61
+ return projectCandidatesFrom(discovery, registry);
62
+ }
63
+ /**
64
+ * The directory of the project a page announced, or undefined when this machine does not know it.
65
+ *
66
+ * A daemon is shared: started from `$HOME`, a monorepo root, or another project's MCP client, its
67
+ * cwd is often not the app that sent the HELLO. The version-skew remedy read `package.json` from
68
+ * cwd anyway, so it fell back to the generic sensor package and told a Next.js project to install
69
+ * `@reticlehq/browser` (#1135). This is the same lookup the artifact root uses, keyed by the page's
70
+ * own project id.
71
+ */
72
+ export function projectDirectoryFor(projectId) {
73
+ if (projectId === undefined)
74
+ return undefined;
75
+ const directories = new Set(knownProjectCandidates()
76
+ .filter((candidate) => candidate.projectId === projectId)
77
+ .map((candidate) => candidate.directory));
78
+ // Two checkouts declaring one id are refused, as the artifact root refuses them: naming one of
79
+ // them would read the wrong app's package.json and prescribe its packages to the other.
80
+ return 1 === directories.size ? [...directories][0] : undefined;
81
+ }
37
82
  export function artifactRootResolver(daemonRoot) {
38
83
  const daemonIsProject = daemonSitsInAProject(daemonRoot);
39
84
  return (projectId, origin) => {
40
- let registry = emptyProjectRegistry();
41
- try {
42
- const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
43
- registry = existsSync(path)
44
- ? parseProjectRegistry(JSON.parse(readFileSync(path, 'utf8')))
45
- : registry;
46
- }
47
- catch {
48
- // A cache that cannot be read is an empty cache, never an error: the daemon still resolves
49
- // through discovery, and falls back to its own root exactly as it did before this existed.
50
- }
51
- let discovery = { found: [], searched: [] };
52
- try {
53
- discovery = discoverProjectConfigs(process.cwd());
54
- }
55
- catch {
56
- // Same reasoning: a diagnostic search that throws must not take a tool call with it.
57
- }
58
85
  const resolved = resolveArtifactRoot({
59
86
  projectId,
60
- candidates: projectCandidatesFrom(discovery, registry),
87
+ candidates: knownProjectCandidates(),
61
88
  daemonRoot,
62
89
  });
63
90
  if (resolved.reason === ArtifactRootReason.MATCHED_PROJECT)
@@ -66,10 +66,11 @@ interface BridgeOptions {
66
66
  */
67
67
  server?: http.Server;
68
68
  /**
69
- * The SDK-upgrade sentence to attach when a HELLO is skewed. Injected so the daemon can name
70
- * this project's packages and package manager; tests that omit it get the no-project fallback.
69
+ * The SDK-upgrade sentence to attach when a HELLO is skewed, for the project that HELLO named.
70
+ * Injected so the daemon can name that project's packages and package manager; tests that omit
71
+ * it get the no-project fallback.
71
72
  */
72
- sdkFix?: () => string;
73
+ sdkFix?: (projectId?: string) => string;
73
74
  }
74
75
  /**
75
76
  * The browser-facing half of the relay: a localhost WebSocket server. Each connection
@@ -608,6 +608,9 @@ export class Bridge {
608
608
  // The daemon is the single judge of skew, and HELLO is where the page announces itself.
609
609
  // Reported on the session (reticle_sessions) AND queued for the next tool result, because an
610
610
  // agent driving a flow never calls reticle_sessions and would never learn.
611
+ // Read only if the page IS skewed: the fix reads the project's package.json and lockfile,
612
+ // and a compatible page (nearly every HELLO) should not pay for that on connect.
613
+ const fixForThisProject = () => this.#sdkFix(parsed.projectId);
611
614
  const skew = describeSkew({
612
615
  what: 'the page',
613
616
  version: parsed.sdkVersion,
@@ -618,7 +621,9 @@ export class Bridge {
618
621
  ...(parsed.contractParts === undefined
619
622
  ? {}
620
623
  : { contractParts: parsed.contractParts }),
621
- fix: this.#sdkFix(),
624
+ get fix() {
625
+ return fixForThisProject();
626
+ },
622
627
  }, {
623
628
  version: SERVER_VERSION,
624
629
  contract: CONTRACT_FINGERPRINT,
@@ -34,8 +34,8 @@ export const VIEWPORT_TOOLS = [
34
34
  recommendation: z.string().optional(),
35
35
  },
36
36
  handler: async (deps, args) => {
37
- const width = clampDim(args['width']);
38
- const height = clampDim(args['height']);
37
+ const width = clampDim('width', args['width']);
38
+ const height = clampDim('height', args['height']);
39
39
  const session = deps.sessions.resolve(asString(args['sessionId']));
40
40
  const provider = viewportProvider(deps);
41
41
  if (provider !== undefined) {
@@ -65,8 +65,18 @@ export const VIEWPORT_TOOLS = [
65
65
  },
66
66
  },
67
67
  ];
68
- /** Clamp a requested dimension into [MIN_DIM, MAX_DIM]; a missing/NaN value falls back to MIN_DIM. */
69
- function clampDim(value) {
70
- const n = 'number' === typeof value && Number.isFinite(value) ? Math.round(value) : MIN_DIM;
71
- return Math.max(MIN_DIM, Math.min(n, MAX_DIM));
68
+ /**
69
+ * Clamp a requested dimension into [MIN_DIM, MAX_DIM]; a missing value falls back to MIN_DIM.
70
+ *
71
+ * A value that is PRESENT but not a finite number is refused, not read as missing: a client that
72
+ * sent `"1440"` meant 1440, and answering with a 64px page it never asked for made every later
73
+ * snapshot, click and screenshot on that page read as broken (#1118).
74
+ */
75
+ function clampDim(key, value) {
76
+ if (value === undefined)
77
+ return MIN_DIM;
78
+ if ('number' !== typeof value || !Number.isFinite(value)) {
79
+ throw new Error(`${ReticleTool.VIEWPORT}: \`${key}\` must be a number of CSS px, got ${JSON.stringify(value)} — NOT applied`);
80
+ }
81
+ return Math.max(MIN_DIM, Math.min(Math.round(value), MAX_DIM));
72
82
  }
@@ -216,16 +216,13 @@ export declare class Session implements HandshakeFacts {
216
216
  total: number;
217
217
  dropped: number;
218
218
  };
219
+ /** Did the event store lose scarce evidence from a window opened at `cursor`? See `windowLost`. */
220
+ lostSince(cursor: number): boolean;
219
221
  /**
220
- * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
221
- * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
222
- * moves for the age and churn evictions that every live page produces continuously. The journal is
223
- * the other half of that store, so a durable read that could not reach back to `cursor` lost
224
- * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
225
- * records share) and an absent `lostThroughT` impeaches every window rather than none — the
226
- * conservative direction, both times.
222
+ * Hold events at/after `cursor` against count-cap eviction until the returned function runs, so a
223
+ * predicate's own match cannot be dropped inside the window it is graded on (#668).
227
224
  */
228
- lostSince(cursor: number): boolean;
225
+ protectWindow(cursor: number): () => void;
229
226
  onEvent(listener: (event: ReticleEvent) => void): () => void;
230
227
  onDisconnect(listener: () => void): () => void;
231
228
  /** Send a command to the browser and await its reply (or time out). */
@@ -14,7 +14,7 @@ import { span } from '../../trace.js';
14
14
  import { keepCallerContextInDaemon, noteToDaemonLog } from './daemon-answers.js';
15
15
  import { isKnownRealm, EventType, HumanControlDataSchema, HumanControlKind, HumanMarkDataSchema, HudUseDataSchema, ReticleCommand, MessageKind, parseEventPayload, PresenterTone, SESSION_HEALTH, SESSION_LEASE, SESSION_LIFECYCLE, SessionState, } from '@reticlehq/core';
16
16
  import { RingBuffer } from '@reticlehq/engine/window/ring-buffer.js';
17
- import { readJournalWriteLoss, readQueryEvents, } from '../../memory/journal/journal-query.js';
17
+ import { readJournalWriteLoss, readQueryEvents, windowLost, } from '../../memory/journal/journal-query.js';
18
18
  import {} from '@reticlehq/engine/window/ambient.js';
19
19
  import { ObservedState, controlLabelsOf } from './facts/observed-state.js';
20
20
  import { recordBrowserLatency, recordHudUse, recordSdkFailure, } from '../../telemetry/session-metrics.js';
@@ -528,20 +528,16 @@ export class Session {
528
528
  bufferHealth() {
529
529
  return this.#buffer.bufferHealth();
530
530
  }
531
+ /** Did the event store lose scarce evidence from a window opened at `cursor`? See `windowLost`. */
532
+ lostSince(cursor) {
533
+ return windowLost(this.#journalReader, this.#buffer, cursor);
534
+ }
531
535
  /**
532
- * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
533
- * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
534
- * moves for the age and churn evictions that every live page produces continuously. The journal is
535
- * the other half of that store, so a durable read that could not reach back to `cursor` lost
536
- * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
537
- * records share) and an absent `lostThroughT` impeaches every window rather than none — the
538
- * conservative direction, both times.
536
+ * Hold events at/after `cursor` against count-cap eviction until the returned function runs, so a
537
+ * predicate's own match cannot be dropped inside the window it is graded on (#668).
539
538
  */
540
- lostSince(cursor) {
541
- if (this.#buffer.lostSince(cursor))
542
- return true;
543
- const lost = this.#journalReader?.readLoss?.();
544
- return lost !== undefined && (lost.lostThroughT === undefined || lost.lostThroughT >= cursor);
539
+ protectWindow(cursor) {
540
+ return this.#buffer.protect(cursor);
545
541
  }
546
542
  onEvent(listener) {
547
543
  this.#listeners.add(listener);
@@ -8,7 +8,7 @@ export interface SharedServer {
8
8
  * so each concurrent Claude Code client needs its own McpServer.
9
9
  * Must be called before listen.
10
10
  */
11
- attachMcp(factory: () => McpServer): void;
11
+ attachMcp(factory: (peerSkew?: string) => McpServer): void;
12
12
  /** Register the JSON the daemon returns from GET /status (live sessions + health for `reticle status`). */
13
13
  /** `held`: the request carried STATUS_HOLD_QUERY — a setup run is waiting on this daemon. */
14
14
  attachStatus(provider: (held: boolean) => unknown): void;
@@ -2,7 +2,7 @@ import * as http from 'node:http';
2
2
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3
3
  import { MCP_SSE_PATH, MCP_MESSAGE_PATH, MCP_SHUTDOWN_EVENT, STATUS_PATH, STATUS_HOLD_QUERY, DRIVE_PATH, } from '@reticlehq/core';
4
4
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
5
- import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM, } from '../command/version/peer-announce.js';
5
+ import { agentPeerSkew, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM, } from '../command/version/peer-announce.js';
6
6
  import { log } from '../log.js';
7
7
  import { isLoopbackHost, isLocalWebOrigin, isLoopbackPeer, requestToken, tokensMatch, } from '../portal/bridge/token-auth.js';
8
8
  import { LOOPBACK_IDLE_MS } from './loopback-agent.js';
@@ -141,7 +141,7 @@ export function createSharedServer(options = {}) {
141
141
  // own check (a stderr line no agent reads), a nudge queued here rides out on the agent's next
142
142
  // tool result. That is the pair the user hits after `npm update`: a cached npx MCP package
143
143
  // talking to a daemon from a different build.
144
- noteAgentPeer(url.searchParams.get(PEER_VERSION_PARAM), url.searchParams.get(PEER_CONTRACT_PARAM));
144
+ const peerSkew = agentPeerSkew(url.searchParams.get(PEER_VERSION_PARAM), url.searchParams.get(PEER_CONTRACT_PARAM));
145
145
  if (mcpFactory === undefined) {
146
146
  res.writeHead(503, { 'Content-Type': 'text/plain' });
147
147
  res.end('MCP server not ready');
@@ -150,7 +150,8 @@ export function createSharedServer(options = {}) {
150
150
  // Fresh McpServer per connection: the MCP SDK's Protocol layer only supports
151
151
  // one active transport per Server instance, so concurrent clients each need
152
152
  // their own instance backed by the same shared ToolDeps.
153
- const mcpServer = mcpFactory();
153
+ // Handed to THIS connection's server only: the skew is about this agent, not the daemon (#1136).
154
+ const mcpServer = mcpFactory(peerSkew);
154
155
  const transport = new SSEServerTransport(MCP_MESSAGE_PATH, res);
155
156
  const sid = transport.sessionId;
156
157
  transports.set(sid, transport);
@@ -662,7 +662,7 @@ tools = tableForSurface(profile)) {
662
662
  content: [
663
663
  {
664
664
  type: 'text',
665
- text: JSON.stringify(liveCallValues(takeVersionSkewOnto(buildErrorPayload(message)), advertisedNames)),
665
+ text: JSON.stringify(liveCallValues(takeVersionSkewOnto(buildErrorPayload(message), deps.peerSkew), advertisedNames)),
666
666
  },
667
667
  ],
668
668
  };
@@ -6,11 +6,9 @@
6
6
  * keep that file from becoming the largest thing on the user's disk.
7
7
  */
8
8
  import { appendFileSync, mkdirSync, renameSync, statSync, truncateSync } from 'node:fs';
9
- import { homedir } from 'node:os';
10
9
  import { join } from 'node:path';
11
- import { ReticleDir } from '@reticlehq/core';
12
10
  import { log } from '../../../log.js';
13
- import { MAX_DAEMON_LOG_BYTES, recoverOversizedLog, rotateDaemonLog, } from '../../../command/daemon/daemon.js';
11
+ import { MAX_DAEMON_LOG_BYTES, recoverOversizedLog, rotateDaemonLog, reticleStateHome, } from '../../../command/daemon/daemon.js';
14
12
  /**
15
13
  * Which port this proxy serves, for the log file name. Set once at startup.
16
14
  *
@@ -79,7 +77,7 @@ export function setProxyLogPort(port) {
79
77
  /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
80
78
  export function proxyLogPath(port = logPort) {
81
79
  const name = port === undefined ? 'mcp-proxy.log' : `proxy-${String(port)}.log`;
82
- return join(homedir(), ReticleDir.ROOT, name);
80
+ return join(reticleStateHome(), name);
83
81
  }
84
82
  /**
85
83
  * Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
@@ -96,7 +94,7 @@ export function proxyLogPath(port = logPort) {
96
94
  export function proxyLog(event, fields = {}) {
97
95
  log(event, fields);
98
96
  try {
99
- const dir = join(homedir(), ReticleDir.ROOT);
97
+ const dir = reticleStateHome();
100
98
  mkdirSync(dir, { recursive: true });
101
99
  // WITH A TIMESTAMP. This file is the only record of an outage that survives the session, and
102
100
  // without one it cannot answer the two questions anyone brings to it: when did this happen, and
@@ -62,8 +62,8 @@ import { readAlreadyTrue } from './act/already-true.js';
62
62
  /**
63
63
  * The action vocabulary, derived from ActionType — never retyped.
64
64
  *
65
- * The description used to list thirteen actions while ActionType had seventeen: blur, upload, drag
66
- * and webmcp were real, callable, and undocumented, because a hand-copied list drifts the moment
65
+ * The description used to omit callable actions such as blur, upload and drag,
66
+ * because a hand-copied list drifts the moment
67
67
  * someone adds an arm. Deriving both the schema and the prose from the enum makes that impossible.
68
68
  *
69
69
  * The handler already refused an unknown action with a good message; the schema now refuses it
@@ -29,6 +29,17 @@ function unknownParamsError(toolName, unknown) {
29
29
  const TOOLS_LIST_ACTION = 'list';
30
30
  /** What to write instead — the one parameter reticle_tools declares, and the no-argument form. */
31
31
  const TOOLS_ARG_HINT = 'name the tools you want in `names`: reticle_tools { names: ["reticle_act_and_wait"] } — or call it with no arguments for the full catalog';
32
+ /**
33
+ * The refusal for arguments of the wrong type, naming each offending key and what it declared.
34
+ * `path` is joined for a nested value (`intents.0.id`); a missing required key reads as the key.
35
+ */
36
+ function invalidParamsError(toolName, issues) {
37
+ const named = issues.map((issue) => {
38
+ const where = 0 === issue.path.length ? '(arguments)' : issue.path.join('.');
39
+ return `${where} (${issue.message})`;
40
+ });
41
+ return `invalid ${1 === named.length ? 'parameter' : 'parameters'} for ${toolName}: ${named.join('; ')} — NOT applied, so any result would be an answer to a different question`;
42
+ }
32
43
  /** The keys not declared by `shape`, in call order. */
33
44
  function unknownKeys(args, shape) {
34
45
  const declared = new Set(Object.keys(shape));
@@ -263,8 +274,24 @@ callable) {
263
274
  hint: 'fix the arguments and call reticle_run again',
264
275
  };
265
276
  }
277
+ // Keys are not enough: the TYPES are the other half of what a direct call's SDK validation
278
+ // refuses. Unchecked here, `reticle_viewport { width: "1440" }` from a client that stringifies
279
+ // numbers reached the handler, which read a non-number as missing and set the page to 64x64,
280
+ // and `reticle_intent { action: "declare", intents: "[...]" }` stored nothing and answered
281
+ // `{ intents: [] }` (#1118). The parsed value goes on, as it does on the direct path, so a
282
+ // schema's defaults and transforms apply the same way through either door.
283
+ const typed = z.object(target.inputSchema).safeParse(callArgs);
284
+ if (!typed.success) {
285
+ return {
286
+ error: invalidParamsError(name, typed.error.issues),
287
+ tool: name,
288
+ params: paramInfo(target.inputSchema),
289
+ ...(target.example === undefined ? {} : { example: target.example }),
290
+ hint: 'fix the arguments and call reticle_run again',
291
+ };
292
+ }
266
293
  try {
267
- return await runTool(target, deps, callArgs);
294
+ return await runTool(target, deps, typed.data);
268
295
  }
269
296
  catch (error) {
270
297
  // The SAME payload the direct tool path builds — recovery included. Answering every failure
@@ -272,7 +299,7 @@ callable) {
272
299
  // is reached through here: a stale ref, a paused session, a missing pairing token all came
273
300
  // back as the agent's arguments being wrong, which is advice that spends the retry.
274
301
  const message = error instanceof Error ? error.message : String(error);
275
- return { ...takeVersionSkewOnto(buildErrorPayload(message)), tool: name };
302
+ return { ...takeVersionSkewOnto(buildErrorPayload(message), deps.peerSkew), tool: name };
276
303
  }
277
304
  },
278
305
  };
@@ -293,8 +293,7 @@ const RULES = [
293
293
  { match: /contenteditable/i, hint: RECOVERY.UNSUPPORTED_SURFACE },
294
294
  { match: /cannot hover without a real pointer/i, hint: RECOVERY.HOVER_NEEDS_POINTER },
295
295
  { match: /cannot \w+ a (disabled|readonly) </i, hint: RECOVERY.NOT_EDITABLE },
296
- // Three spellings ship — "action", "native action", "WebMCP tool" — and the rule matched one, so
297
- // two thirds of the same deliberate refusal still read as a possible defect.
296
+ // Both "action" and "native action" refusals need the same confirmation guidance.
298
297
  {
299
298
  match: /potentially destructive (?:\w+ )*(?:action|tool) blocked/i,
300
299
  hint: RECOVERY.CONFIRM_DANGEROUS,
@@ -18,7 +18,7 @@ import { buildDomainModel } from '../../judgement/domain/domain-model.js';
18
18
  import { readContract } from '../../memory/project/dir/reticle-dir.js';
19
19
  import { sessionRoot } from '../../memory/project/session-root.js';
20
20
  import { buildHarnessPlan, planAsText } from './harness-plan.js';
21
- import { openAiDriver, openAiOptionsFromEnv } from '../../features/harness/openai-driver.js';
21
+ import { openAiDriver, openAiOptionsFromEnv, } from '../../features/harness/openai-driver.js';
22
22
  import { fetchPlatformConfig } from '../../features/harness/platform-config.js';
23
23
  import { DEFAULT_MAX_STEPS, runHarness, } from '../../features/harness/harness.js';
24
24
  import { reticleToolset } from './harness-toolset.js';
@@ -414,6 +414,10 @@ function buildDriver(env, maxSteps, plan, requested, fills) {
414
414
  fillValue: fillValues({
415
415
  secret: (label) => env[secretEnvKey(label)],
416
416
  ...(fills === undefined ? {} : { cache: fills }),
417
+ // GPT first: a linked machine reaches it through the platform with no key of its own.
418
+ ...(openAiOptionsFromEnv(env) === undefined
419
+ ? {}
420
+ : { openai: openAiOptionsFromEnv(env) }),
417
421
  ...(harnessOptionsFromEnv(env) === undefined
418
422
  ? {}
419
423
  : { generator: harnessOptionsFromEnv(env) }),
@@ -351,9 +351,6 @@ export async function runTool(tool, deps, args) {
351
351
  // closure carries this call's own identity, which is also what makes peak-concurrency measurable.
352
352
  // A daemon that has served even one tool call is doing a job for somebody; see daemon-usefulness.
353
353
  noteToolCall();
354
- // The ONBOARD firsts, at the chokepoint every call already passes through — so a second dispatch
355
- // path cannot quietly stop reporting them the way an extra listener would.
356
- noteOnboardingFirst(tool.name);
357
354
  // The impact record needs the project's own `.reticle` root, and this is the first place every
358
355
  // call knows it. Idempotent: the first root wins for the daemon's lifetime.
359
356
  initImpact({ reticleRoot: deps.reticleRoot });
@@ -385,10 +382,6 @@ export async function runTool(tool, deps, args) {
385
382
  // page and then wandered off" becomes a number instead of an impression.
386
383
  if (ACTION_TOOLS.has(tool.name)) {
387
384
  getSessionMetrics().recordAction();
388
- // The two funnel rungs this branch is the authority on: the first action of the run, and the
389
- // fact that this install has been driven at all. Reported from the set that already decides
390
- // what "drove the page" means, so a fourth driving tool inherits both.
391
- noteActed(args);
392
385
  }
393
386
  const settleTiming = getSessionMetrics().startToolCall(tool.name, args);
394
387
  const startedAt = Date.now();
@@ -533,6 +526,17 @@ export async function runTool(tool, deps, args) {
533
526
  // concurrency slot and peakConcurrentTools would climb forever on an unhealthy session.
534
527
  settleTiming(Date.now() - startedAt);
535
528
  }
529
+ // Milestones describe work that ran. Refusals and paused calls must not consume the firsts.
530
+ // Record action before verdict: act_and_wait can complete both in this same call.
531
+ if (isPlainObject(raw) && !resultIsError(raw) && raw['paused'] !== true && raw['ok'] !== false) {
532
+ noteOnboardingFirst(tool.name);
533
+ const effect = raw['effect'];
534
+ const acted = raw['dispatched'] !== false &&
535
+ (!isPlainObject(effect) || effect['dispatched'] !== false) &&
536
+ (tool.name !== ReticleTool.ACT_AND_WAIT || isPlainObject(effect));
537
+ if (ACTION_TOOLS.has(tool.name) && acted)
538
+ noteActed(args);
539
+ }
536
540
  // The human-feedback ask rides out on the first VERIFICATION that completes — the one moment the
537
541
  // experience is fresh and there is something concrete to react to. It is spliced BEFORE the
538
542
  // session-bound early return, because two of the four verification tools (flow_verify,
@@ -584,7 +588,7 @@ export async function runTool(tool, deps, args) {
584
588
  // reticle_sessions.versionSkew and a CLI log line — two places an agent driving a flow never
585
589
  // looks — so it could work a whole session against a mismatched pair and never learn the one fact
586
590
  // that explains the behaviour. It rides out here on whatever tool it happens to be calling.
587
- const skew = isPlainObject(raw) ? takeVersionSkew() : undefined;
591
+ const skew = isPlainObject(raw) ? takeVersionSkew(deps.peerSkew) : undefined;
588
592
  // A feedback report that was accepted and then failed to send. Same one-shot channel, because the
589
593
  // reporter is the only person who can act on it and they are not reading the daemon log — and a
590
594
  // report announced as accepted and then silently lost is the failure the awaited send existed to
@@ -59,6 +59,16 @@ export interface ToolDeps<Ext = unknown> {
59
59
  * `tools.ts`, which keeps every existing `ToolDeps` construction working unchanged.
60
60
  */
61
61
  snapshots?: SnapshotCache;
62
+ /**
63
+ * The skew between THIS client's MCP server and the daemon, told once to this connection only.
64
+ *
65
+ * Per attach for the same reason as `snapshots`: one daemon serves every agent on the machine, and
66
+ * a daemon-wide queue handed one agent's "restart your MCP server" to whichever agent called next,
67
+ * whose server matched (#1136). Absent ⇒ this connection's server matched, or it did not announce.
68
+ */
69
+ peerSkew?: {
70
+ take(): string | undefined;
71
+ };
62
72
  /** cross-run outcome memory (.reticle/project.json). */
63
73
  project: ProjectStore;
64
74
  /** optional native-input provider. undefined ⇒ everything stays synthetic. */
@@ -27,7 +27,7 @@ import { ActionType, FeedbackKind, QueryBy, SnapshotMode } from '@reticlehq/core
27
27
  const SAFE_ENUM_PARAMS = new Map([
28
28
  // Every entry DERIVED from the enum core already owns, never hand-listed. The first version listed
29
29
  // them literally and was wrong on the day it shipped, not merely at risk: `action` was missing
30
- // `fill`, `dblclick`, `submit`, `scrollIntoView`, `drag` and `webmcp` — so `fill`, one of the most
30
+ // `fill`, `dblclick`, `submit`, `scrollIntoView` and `drag` — so `fill`, one of the most
31
31
  // common actions there is, silently reported as `other` — while carrying a `scroll` that
32
32
  // `ActionType` does not define. `mode` mixed SnapshotMode with unrelated session-lifecycle values.
33
33
  // Deriving is not tidiness here; it is the difference between the data being right and being wrong.
@@ -1,9 +1,9 @@
1
- /** A tool call the daemon just ran. Called from the dispatch chokepoint, which sees every one. */
1
+ /** A successful page read. The dispatcher calls this after excluding refusals and pauses. */
2
2
  export declare function noteOnboardingFirst(toolName: string): void;
3
3
  /**
4
4
  * The first ACTION of this run, and the fact that this install has been driven at all.
5
5
  *
6
- * Called from the dispatch chokepoint's own action branch rather than matched on a tool name here.
6
+ * Called after the dispatcher observes a dispatched action, rather than on an attempted call.
7
7
  * The name test was `toolName === ReticleTool.ACT`, on the worry that `act_and_wait` would report
8
8
  * one call as two different firsts — and the worry was backwards: this server's instructions tell
9
9
  * every agent to PREFER `act_and_wait`, so the step measured the tool we ask agents not to use and
@@ -15,7 +15,7 @@ import { reportOnboardingStep } from './onboarding-funnel.js';
15
15
  * drives in another has still only crossed each of these once.
16
16
  */
17
17
  const seen = { look: false, act: false, verdict: false, driven: false };
18
- /** A tool call the daemon just ran. Called from the dispatch chokepoint, which sees every one. */
18
+ /** A successful page read. The dispatcher calls this after excluding refusals and pauses. */
19
19
  export function noteOnboardingFirst(toolName) {
20
20
  if (!seen.look && LOOK_TOOLS.has(toolName)) {
21
21
  seen.look = true;
@@ -29,7 +29,7 @@ export function noteOnboardingFirst(toolName) {
29
29
  /**
30
30
  * The first ACTION of this run, and the fact that this install has been driven at all.
31
31
  *
32
- * Called from the dispatch chokepoint's own action branch rather than matched on a tool name here.
32
+ * Called after the dispatcher observes a dispatched action, rather than on an attempted call.
33
33
  * The name test was `toolName === ReticleTool.ACT`, on the worry that `act_and_wait` would report
34
34
  * one call as two different firsts — and the worry was backwards: this server's instructions tell
35
35
  * every agent to PREFER `act_and_wait`, so the step measured the tool we ask agents not to use and
package/docs/actions.mdx CHANGED
@@ -5,7 +5,7 @@ description: Every action reticle_act and reticle_act_and_wait can perform, with
5
5
  icon: hand-pointer
6
6
  ---
7
7
 
8
- Every action Reticle can perform takes the same shape: a `ref` from [`reticle_look { action: "page" }`](/tools/snapshot) or [`reticle_look { action: "find" }`](/tools/query), an `action` name, and an optional `args` object. The same seventeen actions are available on [`reticle_act`](/tools/act), which proves nothing, and on [`reticle_act_and_wait`](/tools/act-and-wait), which returns a verdict.
8
+ Every action Reticle can perform takes the same shape: a `ref` from [`reticle_look { action: "page" }`](/tools/snapshot) or [`reticle_look { action: "find" }`](/tools/query), an `action` name, and an optional `args` object. The same actions are available on [`reticle_act`](/tools/act), which proves nothing, and on [`reticle_act_and_wait`](/tools/act-and-wait), which returns a verdict.
9
9
 
10
10
  ```json
11
11
  { "ref": "e104", "action": "fill", "args": { "value": "reticle" } }
@@ -26,7 +26,6 @@ Every action Reticle can perform takes the same shape: a `ref` from [`reticle_lo
26
26
  | `scrollIntoView` | none | |
27
27
  | `upload` | `{ content?, name?, type?, path? }` | Sets a file on `<input type="file">`. Inline bytes default to `"reticle test file"` / `"file.txt"` / `"text/plain"`. `path` reads a file from disk, scoped to the project root plus any opt-in directories listed in `.reticle.json` as `"uploadRoots": ["~/Downloads", "/Volumes/fixtures"]`. A refusal names every allowed root. |
28
28
  | `drag` | `{ toRef }` | Pointer-based drag (dnd-kit, react-beautiful-dnd) plus HTML5 DnD. `target` is an alias |
29
- | `webmcp` | `{ tool, params }` | Calls a `navigator.modelContext` tool, if the site exposes one |
30
29
 
31
30
  ### Arguments that are not actions
32
31
 
@@ -1,16 +1,18 @@
1
1
  ---
2
2
  title: reticle cloud commands
3
- description: Sign in, link a repository, and read runs and regressions from Reticle Cloud.
3
+ description: Connect a project in one command, then read runs and regressions from Reticle Cloud.
4
4
  icon: cloud
5
5
  ---
6
6
 
7
- Ten subcommands bind this repository to Reticle Cloud so runs and flows land on a dashboard. They dispatch before the local parser, so `reticle login` is one tool, not a second binary.
7
+ From your app directory, `reticle connect --project "My App"` wires the app, opens browser sign-in approval if needed, links the named cloud project, and syncs the local history already on this machine. Run the [installer](/quickstart) once on the machine first. The commands below remain available when you need to sign in or link separately.
8
8
 
9
9
  ```bash
10
- npx @reticlehq/server <login|logout|whoami|link|project|config|push|runs|regression|share>
10
+ npx @reticlehq/server connect --project "My App"
11
11
  ```
12
12
 
13
- These are thin clients over the `/v1` API. The logic is server-side; these verbs surface it.
13
+ Use `--url https://your-cloud-origin` for a self-hosted cloud. `connect` checks a saved sign-in against that exact host before reuse; if it has expired, the browser approval opens again. It mints or reuses a project-scoped key, so there is no key to paste. A newly wired app may have no run to sync yet: ask your agent to verify one flow and the linked daemon will sync the result. Run `reticle whoami` to check the binding and `reticle push` to send anything still pending.
14
+
15
+ The other cloud commands are thin clients over the `/v1` API. Their logic is server-side.
14
16
 
15
17
  ## Where credentials live
16
18
 
package/docs/cli.mdx CHANGED
@@ -13,13 +13,9 @@ npx @reticlehq/server <command>
13
13
  ```
14
14
 
15
15
  <Warning>
16
- **Always invoke it as `npx @reticlehq/server <command>`.** The tables below name the commands as
17
- `reticle <command>`, because `reticle` is the bin this package installs and that is what the CLI
18
- prints about itself. It is **not** a package on npm: the bare name `reticle` belongs to an
19
- unrelated author, so putting `npx` in front of it downloads and runs their code instead of ours.
20
- Once
21
- `@reticlehq/server` is a dependency of your project, `reticle <command>` works from an npm script
22
- or with the bin on your PATH.
16
+ If you used the installer, run `reticle <command>`: it put that bin on your PATH. Otherwise use
17
+ `npx @reticlehq/server <command>`. Do not put `npx` before the bare `reticle` bin name: the npm
18
+ package with that name belongs to an unrelated author.
23
19
  </Warning>
24
20
 
25
21
  <Note>
@@ -30,6 +26,16 @@ npx @reticlehq/server <command>
30
26
 
31
27
  ## Setup and diagnosis
32
28
 
29
+ ### `reticle connect`
30
+
31
+ From an app directory, one command wires an unwired app, proves a browser session, signs in through browser approval if needed, links the chosen cloud project, and syncs existing local history. A saved sign-in is reused only after the intended cloud host accepts it. Run `reticle init` instead for local-only verification.
32
+
33
+ ```bash
34
+ npx @reticlehq/server connect --project "My App"
35
+ ```
36
+
37
+ For a self-hosted cloud, add `--url <cloud origin>`. If the app was already wired, `connect` keeps its local setup and refreshes the cloud link. A fresh app has no run to send until you verify a flow with your agent.
38
+
33
39
  ### `reticle init`
34
40
 
35
41
  Wires Reticle into the project in the current directory, then boots the app and proves a session connected. It exits non-zero if nothing connected, and prints what is left to do. It does not drive: proving a flow is the FIRST RUN, `reticle verify <url> --explore --persona "<who does what>"`.
package/docs/desktop.mdx CHANGED
@@ -216,7 +216,7 @@ Alternatively, since an Electron renderer _is_ Chromium, `--remote-debugging-por
216
216
 
217
217
  ```toml
218
218
  [dependencies]
219
- reticle-tauri = "3.4"
219
+ reticle-tauri = "3.5"
220
220
  ```
221
221
 
222
222
  ```rust