@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
@@ -21,17 +21,17 @@ declare const SessionSchema: z.ZodObject<{
21
21
  /** Who signed in. Absent in sessions written before it was recorded. */
22
22
  email: z.ZodOptional<z.ZodString>;
23
23
  }, "strip", z.ZodTypeAny, {
24
- url: string;
25
24
  token: string;
25
+ url: string;
26
26
  orgName: string;
27
- email?: string | undefined;
28
27
  orgId?: string | undefined;
28
+ email?: string | undefined;
29
29
  }, {
30
- url: string;
31
30
  token: string;
31
+ url: string;
32
32
  orgName: string;
33
- email?: string | undefined;
34
33
  orgId?: string | undefined;
34
+ email?: string | undefined;
35
35
  }>;
36
36
  export type Session = z.infer<typeof SessionSchema>;
37
37
  /** Trailing slashes are not identity: `https://x/` and `https://x` are one host. */
@@ -53,18 +53,18 @@ export declare const DevicePollSchema: z.ZodObject<{
53
53
  }>>;
54
54
  }, "strip", z.ZodTypeAny, {
55
55
  status: string;
56
+ token?: string | undefined;
56
57
  org?: {
57
58
  name: string;
58
59
  id?: string | undefined;
59
60
  } | undefined;
60
- token?: string | undefined;
61
61
  }, {
62
62
  status: string;
63
+ token?: string | undefined;
63
64
  org?: {
64
65
  name: string;
65
66
  id?: string | undefined;
66
67
  } | undefined;
67
- token?: string | undefined;
68
68
  }>;
69
69
  /**
70
70
  * Best-effort open the approval page in the default browser.
@@ -15,7 +15,7 @@ export { AGENT_FLAG, BUG_FLAG, FEEDBACK_KINDS, KIND_FLAG, RATING_FLAG, } from '.
15
15
  * telling readers to do exactly that on 110 lines before it was caught, so the invocation now leads
16
16
  * the usage block rather than being a footnote somewhere else.
17
17
  */
18
- export declare const CLI_USAGE = "usage: npx @reticlehq/server <command> (or `reticle <command>` once the bin is on your PATH)\n\n reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] [--app <dir>]\n [--env KEY=VALUE]... [--files-only] [--hooks] (wire Reticle into the project in this directory)\n init is ONBOARDING: it wires the project, boots the app and proves a session\n connected. It does not drive. The FIRST RUN is the stage that proves a flow:\n reticle verify <url> --explore --persona \"<who does what>\", or the same thing\n from an agent as reticle_verify { action: \"explore\", persona: \"\u2026\" }\n --app picks WHICH app in a monorepo, when several are found\n --env is what the app needs to reach a usable state: the key from\n .env.example, the mock backend, the variable that skips an auth wall.\n Repeatable, and a value may contain spaces and equals signs\n --files-only writes the files and stops, which is what init did before it\n learned to boot the app and prove the install works\n --relaunch prints the exact command that restarts THIS conversation with\n the tools loaded, so the restart is not a chore handed to a human. It\n refuses when the session id has no transcript behind it, because\n --resume on an empty id opens a blank conversation that looks like\n success. Works with --files-only\n --license writes the key to .env and keeps .env out of git\n --json puts the result on stdout, so an agent reads one object\n --no-open / --no-agents / --url / --timeout are the runtime dials: CI, a\n headless box, or an app you already run\n --no-mcp skips MORE than the server registration: also the agent rule files\n (CLAUDE.md / AGENTS.md / .cursor) and the /reticle command, because all three\n only make sense once the tools are reachable.\n reticle serve [--port N] [--drive <url>] [--headless] [--http] [--http-port N] [--http-token T]\n reticle stop [--port N] [--quiet] (stop the daemon we started, by its recorded pid)\n reticle kill [--port N] [--force] (free the port by its LISTENER, never the agent's mcp proxy)\n reticle restart [--port N] [--force] (kill, then start a daemon and wait for a real bind)\n reticle status [--port N] [--json] (a readable block; --json for the event)\n reticle doctor [--port N] (one command to diagnose setup: Chromium, daemon, port)\n reticle open [url] [--port N] (show the app: reuse the connected tab, else open one)\n reticle verify <url> [--port N] [--headed] [--timeout N] [--storage-state <file>] [--session-id <id>] (one-shot: drive the URL, verify saved flows, exit 0=pass)\n [--explore] [--persona <who>] (no saved flows? let Reticle drive the app itself and record them)\n [--select <label>] (repeatable: verify only flows carrying these labels \u2014 no model, exit 0=pass)\n [--expect '<json predicate>' | --expect-file <path>] (one verdict, no saved\n flows needed. --expect-file is the form no shell can mangle: cmd.exe keeps the\n single quotes the docs show, and PowerShell strips the inner double quotes, and\n a predicate that does not parse produces no verdict at all \u2014 asks the daemon that\n is already running, so nothing is bound and nothing is stopped. It NEEDS that\n daemon: with none on the port it refuses and names the flag, rather than reporting\n on your saved flows instead. Cannot be combined with --storage-state, which this\n path has nowhere to load. This is the path when your client never loaded the\n reticle_* tools. exit 0 ONLY on verified:\"yes\" \u2014 \"unknown\" is not a pass)\n reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)\n reticle gate [--since <ref>] [--accept-coverage] [file...] (exit non-zero unless passing artifacts cover the affected flows)\n reticle report [--session <id>] [--hook] (what the latest session claimed, and what held)\n reticle setup mcp (register the MCP server with your agents; the installer's registration half)\n reticle capsules (list the saved fail-to-pass bug capsules in .reticle/capsules)\n reticle hunt <dir> (aggregate a directory of crawl reports into one false-green rate)\n reticle watch [url] (on save, report which saved flows must re-verify)\n reticle drive <url> [--headless] (foreground mode, for debugging)\n reticle mcp [--port N] [--drive <url>] [--headless] (MCP stdio proxy; auto-starts daemon if needed)\n reticle update (install the latest server version and restart)\n reticle rollback (restore the previous server version and restart)\n reticle license (show enterprise license status: active | eval | missing)\n reticle telemetry [status|enable|disable] (anonymous usage metrics; status shows what's sent + the policy)\n reticle feedback [--rating 1-5] [--bug] \"message\" (tell us what worked and what didn't; prints exactly what it sends)\n reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement|experience> \"message\"\n (agents: file from anywhere, including a setup that never finished)\n reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]\n (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)\n\nCloud (link this project to Reticle; runs/flows recorded on the dashboard):\n reticle login [--url <u>] [--email <e>] [--code <c>] [--org <n>]\n (browser device flow by default; --email mails a code)\n reticle logout [--url <u>] (sign out of ONE host; others stay signed in)\n reticle link [--project <name|id>] (bind this repo: mints a scoped key, writes .reticle/cloud.json)\n reticle whoami (who am I signed in as, and is this repo attached?)\n reticle project <ls|create <name>> (list or create cloud projects)\n reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]\n reticle push | sync [--watch] (one sync cycle: send the difference, collect decisions)\n reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)\n\n'drive' shows the browser; everything else is hidden (serve/mcp own the pool behind leases, replay\nand the spec runner: batch work). --headed opts any of them in, --headless hides drive, CI hides it.";
18
+ export declare const CLI_USAGE = "usage: npx @reticlehq/server <command> (or `reticle <command>` once the bin is on your PATH)\n\n reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] [--app <dir>]\n [--env KEY=VALUE]... [--files-only] [--hooks] (wire Reticle into the project in this directory)\n init is ONBOARDING: it wires the project, boots the app and proves a session\n connected. It does not drive. The FIRST RUN is the stage that proves a flow:\n reticle verify <url> --explore --persona \"<who does what>\", or the same thing\n from an agent as reticle_verify { action: \"explore\", persona: \"\u2026\" }\n --app picks WHICH app in a monorepo, when several are found\n --env is what the app needs to reach a usable state: the key from\n .env.example, the mock backend, the variable that skips an auth wall.\n Repeatable, and a value may contain spaces and equals signs\n --files-only writes the files and stops, which is what init did before it\n learned to boot the app and prove the install works\n --relaunch prints the exact command that restarts THIS conversation with\n the tools loaded, so the restart is not a chore handed to a human. It\n refuses when the session id has no transcript behind it, because\n --resume on an empty id opens a blank conversation that looks like\n success. Works with --files-only\n --license writes the key to .env and keeps .env out of git\n --json puts the result on stdout, so an agent reads one object\n --no-open / --no-agents / --url / --timeout are the runtime dials: CI, a\n headless box, or an app you already run\n --no-mcp skips MORE than the server registration: also the agent rule files\n (CLAUDE.md / AGENTS.md / .cursor) and the /reticle command, because all three\n only make sense once the tools are reachable.\n reticle serve [--port N] [--drive <url>] [--headless] [--http] [--http-port N] [--http-token T]\n reticle stop [--port N] [--quiet] (stop the daemon we started, by its recorded pid)\n reticle kill [--port N] [--force] (free the port by its LISTENER, never the agent's mcp proxy)\n reticle restart [--port N] [--force] (kill, then start a daemon and wait for a real bind)\n reticle status [--port N] [--json] (a readable block; --json for the event)\n reticle doctor [--port N] (one command to diagnose setup: Chromium, daemon, port)\n reticle open [url] [--port N] (show the app: reuse the connected tab, else open one)\n reticle verify <url> [--port N] [--headed] [--timeout N] [--storage-state <file>] [--session-id <id>] (one-shot: drive the URL, verify saved flows, exit 0=pass)\n [--explore] [--persona <who>] (no saved flows? let Reticle drive the app itself and record them)\n [--select <label>] (repeatable: verify only flows carrying these labels \u2014 no model, exit 0=pass)\n [--expect '<json predicate>' | --expect-file <path>] (one verdict, no saved\n flows needed. --expect-file is the form no shell can mangle: cmd.exe keeps the\n single quotes the docs show, and PowerShell strips the inner double quotes, and\n a predicate that does not parse produces no verdict at all \u2014 asks the daemon that\n is already running, so nothing is bound and nothing is stopped. It NEEDS that\n daemon: with none on the port it refuses and names the flag, rather than reporting\n on your saved flows instead. Cannot be combined with --storage-state, which this\n path has nowhere to load. This is the path when your client never loaded the\n reticle_* tools. exit 0 ONLY on verified:\"yes\" \u2014 \"unknown\" is not a pass)\n reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)\n reticle gate [--since <ref>] [--accept-coverage] [file...] (exit non-zero unless passing artifacts cover the affected flows)\n reticle report [--session <id>] [--hook] (what the latest session claimed, and what held)\n reticle setup mcp (register the MCP server with your agents; the installer's registration half)\n reticle capsules (list the saved fail-to-pass bug capsules in .reticle/capsules)\n reticle hunt <dir> (aggregate a directory of crawl reports into one false-green rate)\n reticle watch [url] (on save, report which saved flows must re-verify)\n reticle drive <url> [--headless] (foreground mode, for debugging)\n reticle mcp [--port N] [--drive <url>] [--headless] (MCP stdio proxy; auto-starts daemon if needed)\n reticle update (install the latest server version and restart)\n reticle rollback (restore the previous server version and restart)\n reticle license (show enterprise license status: active | eval | missing)\n reticle telemetry [status|enable|disable] (anonymous usage metrics; status shows what's sent + the policy)\n reticle feedback [--rating 1-5] [--bug] \"message\" (tell us what worked and what didn't; prints exactly what it sends)\n reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement|experience> \"message\"\n (agents: file from anywhere, including a setup that never finished)\n reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]\n (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)\n\nCloud (link this project to Reticle; runs/flows recorded on the dashboard):\n reticle connect [--project <name|id>] [--url <cloud origin>]\n (wire this app if needed, sign in, link, send local history)\n reticle login [--url <u>] [--email <e>] [--code <c>] [--org <n>]\n (browser device flow by default; --email mails a code)\n reticle logout [--url <u>] (sign out of ONE host; others stay signed in)\n reticle link [--project <name|id>] (bind this repo: mints a scoped key, writes .reticle/cloud.json)\n reticle whoami (who am I signed in as, and is this repo attached?)\n reticle project <ls|create <name>> (list or create cloud projects)\n reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]\n reticle push | sync [--watch] (one sync cycle: send the difference, collect decisions)\n reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)\n\n'drive' shows the browser; everything else is hidden (serve/mcp own the pool behind leases, replay\nand the spec runner: batch work). --headed opts any of them in, --headless hides drive, CI hides it.";
19
19
  /** The `reticle telemetry` sub-actions. Bare `reticle telemetry` means `status`. */
20
20
  export declare const TelemetryAction: {
21
21
  readonly STATUS: "status";
@@ -85,6 +85,8 @@ export const CLI_USAGE = `usage: npx @reticlehq/server <command> (or \`reticl
85
85
  (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)
86
86
 
87
87
  Cloud (link this project to Reticle; runs/flows recorded on the dashboard):
88
+ reticle connect [--project <name|id>] [--url <cloud origin>]
89
+ (wire this app if needed, sign in, link, send local history)
88
90
  reticle login [--url <u>] [--email <e>] [--code <c>] [--org <n>]
89
91
  (browser device flow by default; --email mails a code)
90
92
  reticle logout [--url <u>] (sign out of ONE host; others stay signed in)
@@ -170,7 +172,7 @@ const KNOWN_COMMANDS = new Set([
170
172
  FEEDBACK_COMMAND,
171
173
  IDENTIFY_COMMAND,
172
174
  DAEMON_INNER_COMMAND,
173
- // Cloud subcommands dispatch before the local parser but are still commands a human ran.
175
+ 'connect',
174
176
  'login',
175
177
  'logout',
176
178
  'whoami',
@@ -42,6 +42,7 @@ import { diskSink, diskSource, readCloudIssues, readCloudState } from '../../mem
42
42
  */
43
43
  const CLOUD_COMMANDS = new Set([
44
44
  'login',
45
+ 'connect',
45
46
  'logout',
46
47
  'whoami',
47
48
  'link',
@@ -438,6 +439,62 @@ const cmdLink = async (argv) => {
438
439
  }
439
440
  return 0;
440
441
  };
442
+ /**
443
+ * `reticle connect` finishes the cloud half of first-run setup in one invocation. The CLI entry
444
+ * initializes an unwired app before calling this verb. A saved login is checked against this exact
445
+ * host before reuse; an expired one opens the normal browser approval flow. Login does not auto-link
446
+ * here because this command links once, to the project the caller named, then sends local history.
447
+ */
448
+ const cmdConnect = async (argv) => {
449
+ for (let i = 0; i < argv.length; i += 2) {
450
+ const flag = argv[i];
451
+ const value = argv[i + 1];
452
+ if ((flag !== '--project' && flag !== '--url') ||
453
+ value === undefined ||
454
+ 0 === value.length ||
455
+ value.startsWith('--')) {
456
+ err('usage: reticle connect [--project <name|id>] [--url <cloud origin>]');
457
+ return 2;
458
+ }
459
+ }
460
+ if (!(await createNodeFileSystem().exists(join(process.cwd(), RETICLE_CONFIG_BASENAME)))) {
461
+ err('this app is not wired yet — run `reticle init` in its directory, then `reticle connect`');
462
+ return 2;
463
+ }
464
+ const f = flags(argv);
465
+ const url = baseUrl(null, f['url']);
466
+ if (undefined === apiKeyFrom(process.env)) {
467
+ const session = await readSessionFor(url);
468
+ let signedIn = false;
469
+ if (session !== null) {
470
+ try {
471
+ await api('GET', `${url}/v1/me`, session.token);
472
+ signedIn = true;
473
+ }
474
+ catch {
475
+ hint('saved sign-in needs renewal — opening browser approval');
476
+ }
477
+ }
478
+ if (!signedIn) {
479
+ const code = await cmdLogin(['--url', url], cmdLink, false);
480
+ if (code !== 0)
481
+ return code;
482
+ }
483
+ }
484
+ const linkArgs = [
485
+ '--url',
486
+ url,
487
+ ...(f['project'] === undefined ? [] : ['--project', f['project']]),
488
+ ];
489
+ const linked = await cmdLink(linkArgs);
490
+ if (linked !== 0)
491
+ return linked;
492
+ const pushed = await cmdPush();
493
+ if (pushed !== 0)
494
+ return pushed;
495
+ hint('Connected. Open your coding agent and ask it to verify one flow in your app.');
496
+ return 0;
497
+ };
441
498
  /** `reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]`. */
442
499
  const cmdConfig = async (argv) => {
443
500
  const f = flags(argv);
@@ -661,6 +718,8 @@ export const runCloudCommand = async (argv) => {
661
718
  // The linker is passed in rather than imported by cloud-login: login ends by linking and
662
719
  // link needs a session, so the cycle is broken at this one call site.
663
720
  return await cmdLogin(rest, cmdLink);
721
+ case 'connect':
722
+ return await cmdConnect(rest);
664
723
  case 'logout':
665
724
  return await cmdLogout(rest);
666
725
  case 'whoami':
@@ -4,7 +4,7 @@ type Linker = (argv: readonly string[]) => Promise<number>;
4
4
  * `reticle login` — browser device flow by default; `--email <e>` (or a positional email) keeps the
5
5
  * headless two-step code path for CI/servers where opening a browser makes no sense.
6
6
  */
7
- export declare const cmdLogin: (argv: readonly string[], link: Linker) => Promise<number>;
7
+ export declare const cmdLogin: (argv: readonly string[], link: Linker, autoLink?: boolean) => Promise<number>;
8
8
  /** `reticle logout` — forget the cached session token (per-project keys under credentials.json stay). */
9
9
  /**
10
10
  * `reticle logout` — sign out of ONE host, not of everywhere.
@@ -48,7 +48,7 @@ const signedInEmail = async (url, token) => {
48
48
  }
49
49
  };
50
50
  /** Persist a session token under ~/.reticle and print the next step. Shared by both login paths. */
51
- const writeSession = async (url, token, orgName, orgId, project, link, email) => {
51
+ const writeSession = async (url, token, orgName, orgId, project, link, email, autoLink = true) => {
52
52
  await mkdir(join(home(), SESSIONS_DIR), { recursive: true });
53
53
  // Who signed in, so a flow saved on this machine can say which person made it (FlowFile.author).
54
54
  const who = email ?? (await signedInEmail(url, token));
@@ -65,7 +65,8 @@ const writeSession = async (url, token, orgName, orgId, project, link, email) =>
65
65
  await writeFile(sessionPath(home(), url), body);
66
66
  await writeFile(join(home(), SESSION_FILE), body);
67
67
  emit({ loggedIn: orgName, session: join(home(), SESSION_FILE) });
68
- await linkAfterLogin(url, project, link);
68
+ if (autoLink)
69
+ await linkAfterLogin(url, project, link);
69
70
  };
70
71
  /**
71
72
  * Finish the job when the shell is already standing in an unlinked Reticle project.
@@ -124,7 +125,7 @@ const linkAfterLogin = async (url, project, link) => {
124
125
  * Browser device flow — the DEFAULT `reticle login` (like `gh auth login`): fetch a device + user code,
125
126
  * open the browser to approve, then poll until the user confirms. No email to type, no code to copy back.
126
127
  */
127
- const cmdLoginDevice = async (explicitUrl, project, link) => {
128
+ const cmdLoginDevice = async (explicitUrl, project, link, autoLink) => {
128
129
  const url = baseUrl(null, explicitUrl);
129
130
  const started = DeviceStartSchema.parse(await api('POST', `${url}/v1/auth/device/start`, null, {}));
130
131
  hint(`Opening ${started.verificationUri} — confirm this code in the browser: ${started.userCode}`);
@@ -134,7 +135,7 @@ const cmdLoginDevice = async (explicitUrl, project, link) => {
134
135
  await sleep(intervalMs);
135
136
  const poll = DevicePollSchema.parse(await api('POST', `${url}/v1/auth/device/token`, null, { deviceCode: started.deviceCode }));
136
137
  if ('approved' === poll.status && poll.token !== undefined && poll.org !== undefined) {
137
- await writeSession(url, poll.token, poll.org.name, poll.org.id, project, link);
138
+ await writeSession(url, poll.token, poll.org.name, poll.org.id, project, link, undefined, autoLink);
138
139
  return 0;
139
140
  }
140
141
  if ('pending' === poll.status) {
@@ -154,12 +155,12 @@ const cmdLoginDevice = async (explicitUrl, project, link) => {
154
155
  * `reticle login` — browser device flow by default; `--email <e>` (or a positional email) keeps the
155
156
  * headless two-step code path for CI/servers where opening a browser makes no sense.
156
157
  */
157
- export const cmdLogin = async (argv, link) => {
158
+ export const cmdLogin = async (argv, link, autoLink = true) => {
158
159
  const f = flags(argv);
159
160
  const positional = argv[0] !== undefined && !argv[0].startsWith('--') ? argv[0] : undefined;
160
161
  const email = f['email'] ?? positional;
161
162
  if (email === undefined)
162
- return cmdLoginDevice(f['url'], f['project'], link);
163
+ return cmdLoginDevice(f['url'], f['project'], link, autoLink);
163
164
  const org = f['org'];
164
165
  const url = baseUrl(null, f['url']);
165
166
  let code = f['code'];
@@ -177,7 +178,7 @@ export const cmdLogin = async (argv, link) => {
177
178
  code = requested.devCode;
178
179
  }
179
180
  const parsed = LoginSchema.parse(await api('POST', `${url}/v1/auth/login`, null, { email, code }));
180
- await writeSession(url, parsed.token, parsed.org.name, parsed.org.id, f['project'], link, email);
181
+ await writeSession(url, parsed.token, parsed.org.name, parsed.org.id, f['project'], link, email, autoLink);
181
182
  return 0;
182
183
  };
183
184
  /** `reticle logout` — forget the cached session token (per-project keys under credentials.json stay). */
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { probeCli, terminalWidth, wrapForTerminal } from '@reticlehq/init';
4
+ import { claudeAddCommand, McpClient, probeCli, terminalWidth, wrapForTerminal, } from '@reticlehq/init';
5
5
  import { installClosing, tutorialShownSteps } from './tutorial.js';
6
6
  import { setupMcp, knownClientLabels } from '../setup/setup-mcp.js';
7
7
  import { reportInstallSteps } from '../setup/setup-install.js';
@@ -49,6 +49,14 @@ export function handleSetupMcp(reportStep, standalone = true) {
49
49
  io.print(` ✓ ${id}`);
50
50
  for (const id of result.alreadyThere)
51
51
  io.print(` ✓ ${id} (already set up)`);
52
+ for (const id of result.failed) {
53
+ io.print(` ⚠ ${id}: Reticle registration failed; this agent may not have the tools.`);
54
+ io.print(id === McpClient.CLAUDE_CODE
55
+ ? ` Run this command to see the error and retry: ${claudeAddCommand().display}`
56
+ : ' Check your agent config file permissions, then retry: reticle setup mcp');
57
+ }
58
+ if (result.failed.length > 0)
59
+ process.exitCode = 1;
52
60
  // Said, not skipped. These are the clients whose format we will not rewrite, and they used to be
53
61
  // counted as registered: the line below is the difference between a user who adds four lines to
54
62
  // one file and a user who believes they are set up and has no tools.
@@ -79,7 +79,7 @@ async function handleInit(parsed) {
79
79
  daemonPresent: async (p) => presenceIsUsable(await probePresence(p, { tcpOpen: probeDaemon, status: fetchStatus })),
80
80
  pickPort: (p) => pickDaemonPortToBind(p),
81
81
  });
82
- const io = buildNodeIo(cwd, serverInitHost());
82
+ const io = buildNodeIo(cwd, serverInitHost(), { stderr: true === parsed.json });
83
83
  const result = runInit({
84
84
  cwd,
85
85
  port,
@@ -587,6 +587,22 @@ export function main() {
587
587
  process.stdout.write(`${CLI_USAGE}\n`);
588
588
  return;
589
589
  }
590
+ if ('connect' === argv[0]) {
591
+ void (async () => {
592
+ // A fresh project gets the same install, dev-server handover and browser proof as `init`.
593
+ // Its failure exits non-zero before any cloud binding can claim this repo is ready.
594
+ if (readProjectId(process.cwd()) === undefined) {
595
+ await handleInit({ port: undefined, mcp: true, dryRun: false, install: true });
596
+ }
597
+ return runCloudCommand(argv);
598
+ })()
599
+ .then((code) => process.exit(code))
600
+ .catch((cause) => {
601
+ process.stderr.write(`reticle connect: ${cause instanceof Error ? cause.message : String(cause)}\n`);
602
+ process.exit(1);
603
+ });
604
+ return;
605
+ }
590
606
  void runCloudCommand(argv).then((code) => process.exit(code));
591
607
  return;
592
608
  }
@@ -25,6 +25,8 @@ export interface WaitFacts {
25
25
  readonly quietForMs: number;
26
26
  /** Milliseconds since the wait began. */
27
27
  readonly elapsedMs: number;
28
+ /** Explicit caller limit; otherwise a busy build keeps the default ceiling. */
29
+ readonly budgetMs?: number | undefined;
28
30
  /**
29
31
  * How long silence may mean "still starting" on THIS machine. Defaults to QUIET_MEANS_HUNG_MS.
30
32
  *
@@ -38,7 +38,7 @@ export function judgeWait(facts) {
38
38
  return WaitVerdict.READY;
39
39
  if (facts.launcherExited)
40
40
  return WaitVerdict.DEAD;
41
- if (facts.elapsedMs >= WAIT_CEILING_MS)
41
+ if (facts.elapsedMs >= (facts.budgetMs ?? WAIT_CEILING_MS))
42
42
  return WaitVerdict.HUNG;
43
43
  // Output IS progress. Silence is what distinguishes a build from a wedge.
44
44
  return facts.quietForMs >= (facts.quietMeansHungMs ?? QUIET_MEANS_HUNG_MS)
@@ -6,8 +6,8 @@
6
6
  * every command harder to read.
7
7
  */
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
- import { RETICLE_DEFAULT_PORT } from '@reticlehq/core';
10
- import { FEEDBACK_HINT, Framework } from '@reticlehq/init';
9
+ import { InitConfirmation, RETICLE_DEFAULT_PORT } from '@reticlehq/core';
10
+ import { FEEDBACK_HINT, Framework, InitFailure } from '@reticlehq/init';
11
11
  import { confirmInstall, nodeConfirmDeps } from '../terminal/confirm.js';
12
12
  import { writeLicenseKey } from '../license-key.js';
13
13
  import { registerOtherAgents, runSetupCommand } from '../setup-command.js';
@@ -20,6 +20,8 @@ import { probeDaemon } from '../../../surface/mcp/proxy/proxy-daemon-probe.js';
20
20
  import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
21
21
  import { collectEnv, DEFAULT_PHASE_TIMEOUT_MS } from '../setup-options.js';
22
22
  import { staticPageDevCommand } from '../bringup/static-page-server.js';
23
+ import { SetupPhase } from '../run-setup.js';
24
+ import { reportInitOutcome } from '../../../telemetry/init-telemetry.js';
23
25
  /** How often the runtime phases look again: fast enough not to be the wait, slow enough to be free. */
24
26
  const POLL_MS = 250;
25
27
  /**
@@ -59,9 +61,9 @@ function printRelaunch(parsed, io, cwd) {
59
61
  }
60
62
  export async function continueAfterInit(parsed, result, io, cwd) {
61
63
  const port = parsed.port ?? RETICLE_DEFAULT_PORT;
62
- // Before anything else: the key belongs in .env whichever way this run ends, and the CLI folds a
63
- // project-local .env into the environment on every invocation.
64
- if (undefined !== parsed.licenseKey) {
64
+ // Real installs persist the key whichever way setup ends. A preview must leave .env and
65
+ // .gitignore untouched, just as it leaves project wiring and agent configuration untouched.
66
+ if (!parsed.dryRun && undefined !== parsed.licenseKey) {
65
67
  const written = writeLicenseKey(cwd, parsed.licenseKey, licenseIo);
66
68
  io.print(written.message);
67
69
  }
@@ -79,9 +81,12 @@ export async function continueAfterInit(parsed, result, io, cwd) {
79
81
  // has: nothing reaches back into a machine that installed Reticle a version ago, so the
80
82
  // upgrade path is re-running init, and the light form of init has to be enough to carry it.
81
83
  // A dry run writes nothing anywhere, including here.
82
- if (true === parsed.filesOnly && wantsAgents(parsed))
84
+ if (!parsed.dryRun && true === parsed.filesOnly && wantsAgents(parsed)) {
83
85
  registerOtherAgents(io.print);
86
+ }
84
87
  return confirmInstall(result, io, nodeConfirmDeps(port)).then(() => {
88
+ if (true === parsed.json)
89
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
85
90
  if (!result.ok)
86
91
  process.exit(1);
87
92
  });
@@ -96,8 +101,15 @@ export async function continueAfterInit(parsed, result, io, cwd) {
96
101
  if (context === undefined) {
97
102
  // Nothing was established, so there is nothing to run against. init has already said why.
98
103
  ask();
104
+ if (true === parsed.json)
105
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
99
106
  process.exit(1);
100
107
  }
108
+ const reportFailure = (reason) => {
109
+ if (result.outcome !== undefined) {
110
+ reportInitOutcome({ ...result.outcome, ok: false, reason });
111
+ }
112
+ };
101
113
  // A PENDING connect step is not a reason to skip the runtime phase, and treating it as one made
102
114
  // `init` stop with "paste this snippet" while never looking at the app.
103
115
  //
@@ -113,10 +125,17 @@ export async function continueAfterInit(parsed, result, io, cwd) {
113
125
  // Before the connect wait, never after: a bridge held by a stranger makes a session impossible,
114
126
  // so going ahead spends the entire budget and then reports what reads as an instrumentation
115
127
  // problem — the one place that is fine. See bridge-port.ts.
116
- const refusal = bridgeOccupied(await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }), port);
128
+ const refusal = bridgeOccupied(await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).catch((error) => {
129
+ reportFailure(InitFailure.RUNTIME_ERROR);
130
+ throw error;
131
+ }), port);
117
132
  if (refusal !== undefined) {
133
+ reportFailure(InitFailure.BRIDGE_OCCUPIED);
118
134
  io.print(refusal);
119
135
  ask();
136
+ if (true === parsed.json) {
137
+ process.stdout.write(`${JSON.stringify({ ok: false, reason: InitFailure.BRIDGE_OCCUPIED }, null, 2)}\n`);
138
+ }
120
139
  process.exit(1);
121
140
  }
122
141
  return runSetupCommand({
@@ -136,7 +155,10 @@ export async function continueAfterInit(parsed, result, io, cwd) {
136
155
  // keeps deciding, so nobody who passed nothing waits less than they used to.
137
156
  ...(undefined === parsed.timeoutSeconds
138
157
  ? {}
139
- : { connectBudgetMs: parsed.timeoutSeconds * 1000 }),
158
+ : {
159
+ connectBudgetMs: parsed.timeoutSeconds * 1000,
160
+ startupBudgetMs: parsed.timeoutSeconds * 1000,
161
+ }),
140
162
  pollMs: POLL_MS,
141
163
  htmlCarriesSdk: HTML_CARRIES_SDK.has(context.framework),
142
164
  // A plain page with no dev script gets Reticle's own static server, so the page is actually
@@ -153,6 +175,26 @@ export async function continueAfterInit(parsed, result, io, cwd) {
153
175
  })(),
154
176
  ...(undefined === parsed.url ? {} : { suppliedUrl: parsed.url }),
155
177
  }, (line) => io.print(line)).then((outcome) => {
178
+ if (result.outcome !== undefined) {
179
+ const init = {
180
+ ...result.outcome,
181
+ ok: outcome.ok,
182
+ };
183
+ if (outcome.ok) {
184
+ delete init.reason;
185
+ init.confirmation = InitConfirmation.CONNECTED;
186
+ }
187
+ else {
188
+ // A failed startup does not establish whether a daemon or instrumented page exists.
189
+ // Keep the watcher's no_daemon/no_session/no_page classifications for observed facts.
190
+ delete init.confirmation;
191
+ init.reason =
192
+ outcome.reachedPhase === SetupPhase.DEV_SERVER
193
+ ? InitFailure.DEV_SERVER
194
+ : InitFailure.APP_CONNECTION;
195
+ }
196
+ reportInitOutcome(init);
197
+ }
156
198
  // One object, so an agent reads a result instead of interpreting a report.
157
199
  if (true === parsed.json) {
158
200
  process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
@@ -201,5 +243,8 @@ export async function continueAfterInit(parsed, result, io, cwd) {
201
243
  io.print(` ${i + 1}. ${step}`);
202
244
  ask();
203
245
  process.exit(1);
246
+ }, (error) => {
247
+ reportFailure(InitFailure.RUNTIME_ERROR);
248
+ throw error;
204
249
  });
205
250
  }
@@ -23,6 +23,8 @@ import { reticleStateHome } from '../daemon/daemon.js';
23
23
  import { descendants, parseLsofPorts, parseNetstatListeners, parseWmicProcesses, } from './terminal/listeners.js';
24
24
  import { isAddressMiss, loopbackProbeUrls } from './probe/loopback-probe-urls.js';
25
25
  const WINDOWS = 'win32' === process.platform;
26
+ /** Astro otherwise daemonizes automatically when it detects an agent terminal. */
27
+ const ASTRO_DEV_BACKGROUND_ENV = 'ASTRO_DEV_BACKGROUND';
26
28
  /** A page fetch that is slow is a page fetch that failed, for our purposes. */
27
29
  const PROBE_TIMEOUT_MS = 5_000;
28
30
  /** The supervisor that owns the server's output and keeps its log bounded. */
@@ -64,7 +66,10 @@ export class OwnedDevServer {
64
66
  detached: !WINDOWS,
65
67
  // Nothing of ours: the supervisor holds the server's pipes, and init holds none of its.
66
68
  stdio: 'ignore',
67
- env: { ...process.env, ...env },
69
+ // Reticle already provides the detached supervisor. Astro's agent auto-backgrounding
70
+ // would escape its process group and leave the recorded handover PID pointing at a dead
71
+ // launcher, so re-init and cleanup could no longer find or stop the actual server.
72
+ env: { ...process.env, ...env, [ASTRO_DEV_BACKGROUND_ENV]: '1' },
68
73
  });
69
74
  this.log = log;
70
75
  this.cwd = cwd;
@@ -132,6 +132,8 @@ export interface SetupInput {
132
132
  * before setup could say what was wrong.
133
133
  */
134
134
  readonly connectBudgetMs?: number | undefined;
135
+ /** Explicit --timeout for startup; absent preserves the adaptive build wait. */
136
+ readonly startupBudgetMs?: number | undefined;
135
137
  readonly openBrowser: boolean;
136
138
  /** Web, Electron or Tauri. Desktop changes three things; see desktop-shape.ts. */
137
139
  readonly shape: AppShape;
@@ -159,6 +159,7 @@ export async function runSetupPhases(input, fx) {
159
159
  serving,
160
160
  quietForMs: fx.devServerQuietForMs(),
161
161
  elapsedMs: fx.now() - startedAt,
162
+ budgetMs: input.startupBudgetMs,
162
163
  quietMeansHungMs: WINDOWS_QUIET_MEANS_HUNG_MS_APPLIES
163
164
  ? WINDOWS_QUIET_MEANS_HUNG_MS
164
165
  : QUIET_MEANS_HUNG_MS,
@@ -184,8 +185,12 @@ export async function runSetupPhases(input, fx) {
184
185
  // Says BOTH were checked. A server that prints nothing but IS listening is the CRA case and
185
186
  // must not be failed, so a reader has to be able to tell "we looked at the log" from "we
186
187
  // looked at the log AND the ports".
187
- note('The dev server neither printed a URL nor bound a port, so setup has nothing to open. ' +
188
- 'Check its log, or pass --url with the address it serves.');
188
+ note((input.startupBudgetMs !== undefined && fx.now() - startedAt >= input.startupBudgetMs
189
+ ? `The dev server did not become ready within ${String(input.startupBudgetMs)}ms. `
190
+ : undefined === watching
191
+ ? 'The dev server neither printed a URL nor bound a port, so setup has nothing to open. '
192
+ : `The dev server stopped making progress before ${watching} became ready. `) +
193
+ 'Check its log, increase --timeout for a slow build, or pass --url with the address it serves.');
189
194
  return stop(input, SetupPhase.DEV_SERVER, {}, notes);
190
195
  }
191
196
  await fx.sleep(input.pollMs);
@@ -32,6 +32,8 @@ export interface SetupMcpResult {
32
32
  readonly detected: readonly string[];
33
33
  readonly registered: readonly string[];
34
34
  readonly alreadyThere: readonly string[];
35
+ /** Automatic registration was attempted but failed. */
36
+ readonly failed: readonly string[];
35
37
  /**
36
38
  * Clients whose config this will NOT rewrite, and which therefore still need a human.
37
39
  *
@@ -4,6 +4,7 @@ export function setupMcp(io) {
4
4
  const detected = [];
5
5
  const registered = [];
6
6
  const alreadyThere = [];
7
+ const failed = [];
7
8
  const manual = [];
8
9
  /*
9
10
  * Claude Code first, because nothing else can find it.
@@ -30,6 +31,8 @@ export function setupMcp(io) {
30
31
  const cmd = claudeAddCommand();
31
32
  if (io.runCli(cmd.command, cmd.args))
32
33
  registered.push(McpClient.CLAUDE_CODE);
34
+ else
35
+ failed.push(McpClient.CLAUDE_CODE);
33
36
  }
34
37
  }
35
38
  for (const client of detectMcpClients(io)) {
@@ -47,8 +50,13 @@ export function setupMcp(io) {
47
50
  manual.push({ id: client.id, configPath: client.configPath, docs: spec.docs });
48
51
  continue;
49
52
  }
50
- io.writeFile(client.configPath, merged.content);
51
- registered.push(client.id);
53
+ try {
54
+ io.writeFile(client.configPath, merged.content);
55
+ registered.push(client.id);
56
+ }
57
+ catch {
58
+ failed.push(client.id);
59
+ }
52
60
  }
53
61
  /*
54
62
  * Both INSTALL steps, reported from the one place that knows the answer.
@@ -69,7 +77,8 @@ export function setupMcp(io) {
69
77
  * SKIPPED when there was nothing to register with — not failed. Nothing went wrong; there was
70
78
  * simply no agent here, and counting that as our failure would hide the ones that are.
71
79
  *
72
- * FAILED when every client we found needs a hand. That case used to fall through to COMPLETED,
80
+ * FAILED when no detected client is usable, whether registration failed or needs a hand.
81
+ * That case used to fall through to COMPLETED,
73
82
  * so the cohort with the tools registered NOWHERE was counted among the successful installs, and
74
83
  * the one number that says "did this machine get Reticle" read yes for the people it read no
75
84
  * for. Registering some and not others is still COMPLETED: the agent they are using may well be
@@ -79,11 +88,11 @@ export function setupMcp(io) {
79
88
  ? OnboardingStepStatus.SKIPPED
80
89
  : 0 === registered.length && alreadyThere.length > 0
81
90
  ? OnboardingStepStatus.SKIPPED
82
- : 0 === registered.length && 0 === alreadyThere.length && manual.length > 0
91
+ : 0 === registered.length && 0 === alreadyThere.length
83
92
  ? OnboardingStepStatus.FAILED
84
93
  : OnboardingStepStatus.COMPLETED,
85
94
  });
86
- return { detected, registered, alreadyThere, manual };
95
+ return { detected, registered, alreadyThere, failed, manual };
87
96
  }
88
97
  /** Everything this machine could be asked about, for the report when nothing was found. */
89
98
  export function knownClientLabels() {
@@ -1,4 +1,8 @@
1
1
  export declare const PEER_VERSION_PARAM = "peerVersion";
2
2
  export declare const PEER_CONTRACT_PARAM = "peerContract";
3
- /** Compare an attaching agent process against this daemon, and queue the nudge when they disagree. */
4
- export declare function noteAgentPeer(version: string | null, contract: string | null): void;
3
+ /**
4
+ * Compare an attaching agent process against this daemon: the skew to tell THAT connection, or
5
+ * undefined when they agree. Returned rather than queued daemon-wide, because it is true only for the
6
+ * agent that announced it (#1136).
7
+ */
8
+ export declare function agentPeerSkew(version: string | null, contract: string | null): string | undefined;
@@ -13,12 +13,15 @@
13
13
  */
14
14
  import { CONTRACT_FINGERPRINT } from '@reticlehq/core';
15
15
  import { SERVER_VERSION } from './identity/server-version.js';
16
- import { daemonFix, describeSkew, SkewPair } from './version-skew.js';
17
- import { noteVersionSkew } from './version-nudge.js';
16
+ import { daemonFix, describeSkew } from './version-skew.js';
18
17
  export const PEER_VERSION_PARAM = 'peerVersion';
19
18
  export const PEER_CONTRACT_PARAM = 'peerContract';
20
- /** Compare an attaching agent process against this daemon, and queue the nudge when they disagree. */
21
- export function noteAgentPeer(version, contract) {
19
+ /**
20
+ * Compare an attaching agent process against this daemon: the skew to tell THAT connection, or
21
+ * undefined when they agree. Returned rather than queued daemon-wide, because it is true only for the
22
+ * agent that announced it (#1136).
23
+ */
24
+ export function agentPeerSkew(version, contract) {
22
25
  const skew = describeSkew({
23
26
  what: "the agent's MCP server",
24
27
  version: version ?? undefined,
@@ -27,6 +30,5 @@ export function noteAgentPeer(version, contract) {
27
30
  // MCP server, so the daemon is the newer half whenever the announcing agent is behind.
28
31
  fix: daemonFix(SERVER_VERSION, version ?? undefined),
29
32
  }, { version: SERVER_VERSION, contract: CONTRACT_FINGERPRINT });
30
- if (skew !== undefined)
31
- noteVersionSkew(SkewPair.DAEMON, skew);
33
+ return skew;
32
34
  }