@reticlehq/server 2.5.0 → 2.6.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 (147) hide show
  1. package/SKILL.md +27 -10
  2. package/dist/bridge/bridge.d.ts +12 -0
  3. package/dist/bridge/bridge.js +32 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/cli-doctor.d.ts +0 -5
  7. package/dist/cli/cli-doctor.js +71 -3
  8. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  9. package/dist/cli/cli-parse-feedback.js +56 -0
  10. package/dist/cli/cli-parse.d.ts +5 -9
  11. package/dist/cli/cli-parse.js +7 -23
  12. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  13. package/dist/cli/doctor-daemon-line.js +35 -0
  14. package/dist/cli/port-holder.d.ts +43 -0
  15. package/dist/cli/port-holder.js +99 -0
  16. package/dist/cli.js +131 -11
  17. package/dist/daemon/daemon-resilience.d.ts +24 -0
  18. package/dist/daemon/daemon-resilience.js +105 -17
  19. package/dist/daemon/daemon.js +26 -1
  20. package/dist/daemon/heartbeat.d.ts +115 -0
  21. package/dist/daemon/heartbeat.js +141 -0
  22. package/dist/daemon/idle-shutdown.d.ts +13 -5
  23. package/dist/daemon/idle-shutdown.js +14 -6
  24. package/dist/daemon/port-presence.d.ts +76 -0
  25. package/dist/daemon/port-presence.js +93 -0
  26. package/dist/daemon/state-dir.d.ts +7 -0
  27. package/dist/daemon/state-dir.js +59 -0
  28. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  29. package/dist/daemon/unclean-predecessor.js +27 -0
  30. package/dist/events/predicate-eval.d.ts +10 -0
  31. package/dist/events/predicate-eval.js +24 -4
  32. package/dist/events/predicate-parse.d.ts +23 -0
  33. package/dist/events/predicate-parse.js +54 -0
  34. package/dist/events/predicate.d.ts +5 -0
  35. package/dist/events/predicate.js +17 -3
  36. package/dist/events/ring-buffer.js +1 -16
  37. package/dist/flows/flow-replay.d.ts +1 -1
  38. package/dist/flows/flow-replay.js +19 -2
  39. package/dist/flows/flow-step-runners.js +2 -3
  40. package/dist/flows/flow-tools.js +10 -14
  41. package/dist/flows/role-drift-reason.d.ts +20 -0
  42. package/dist/flows/role-drift-reason.js +39 -0
  43. package/dist/flows/suite-flakes.d.ts +33 -0
  44. package/dist/flows/suite-flakes.js +32 -0
  45. package/dist/honesty/blind-spots.d.ts +19 -0
  46. package/dist/honesty/blind-spots.js +33 -0
  47. package/dist/honesty/verified.d.ts +10 -1
  48. package/dist/honesty/verified.js +16 -0
  49. package/dist/http-server.js +9 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +10 -1
  52. package/dist/init/agent-rules.d.ts +18 -0
  53. package/dist/init/agent-rules.js +22 -4
  54. package/dist/init/cra.d.ts +13 -0
  55. package/dist/init/cra.js +16 -0
  56. package/dist/init/detect.d.ts +6 -0
  57. package/dist/init/detect.js +8 -0
  58. package/dist/init/mcp-clients.d.ts +81 -0
  59. package/dist/init/mcp-clients.js +248 -0
  60. package/dist/init/node-io.js +8 -2
  61. package/dist/init/plan-framework.js +36 -2
  62. package/dist/init/plan.d.ts +14 -0
  63. package/dist/init/plan.js +73 -9
  64. package/dist/init/run.d.ts +18 -1
  65. package/dist/init/run.js +133 -16
  66. package/dist/init/windows-quote.d.ts +38 -0
  67. package/dist/init/windows-quote.js +70 -0
  68. package/dist/mcp/mcp-outage.d.ts +1 -0
  69. package/dist/mcp/mcp-outage.js +8 -1
  70. package/dist/mcp/mcp-proxy.d.ts +11 -1
  71. package/dist/mcp/mcp-proxy.js +30 -7
  72. package/dist/mcp/mcp.js +29 -2
  73. package/dist/pool/browser-pool.d.ts +9 -0
  74. package/dist/pool/browser-pool.js +38 -9
  75. package/dist/session/attachment-history.d.ts +46 -0
  76. package/dist/session/attachment-history.js +65 -0
  77. package/dist/session/no-session-diagnosis.d.ts +7 -0
  78. package/dist/session/no-session-diagnosis.js +53 -3
  79. package/dist/session/no-session-watch.d.ts +3 -1
  80. package/dist/session/no-session-watch.js +10 -2
  81. package/dist/session/pending-commands.js +1 -0
  82. package/dist/session/session-info.d.ts +17 -1
  83. package/dist/session/session-info.js +5 -1
  84. package/dist/session/session-manager.d.ts +3 -0
  85. package/dist/session/session-manager.js +72 -3
  86. package/dist/session/session.d.ts +1 -0
  87. package/dist/session/session.js +8 -0
  88. package/dist/telemetry/crash-cause.d.ts +47 -0
  89. package/dist/telemetry/crash-cause.js +104 -0
  90. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  91. package/dist/telemetry/daemon-telemetry.js +28 -13
  92. package/dist/telemetry/error-class.d.ts +26 -0
  93. package/dist/telemetry/error-class.js +52 -0
  94. package/dist/telemetry/feedback-cli.d.ts +7 -2
  95. package/dist/telemetry/feedback-cli.js +49 -5
  96. package/dist/telemetry/feedback-context.d.ts +1 -6
  97. package/dist/telemetry/feedback-context.js +64 -4
  98. package/dist/telemetry/git-facts.d.ts +7 -0
  99. package/dist/telemetry/git-facts.js +4 -4
  100. package/dist/telemetry/project-profile.js +2 -1
  101. package/dist/telemetry/session-metrics.d.ts +45 -3
  102. package/dist/telemetry/session-metrics.js +247 -9
  103. package/dist/telemetry/telemetry.d.ts +19 -1
  104. package/dist/telemetry/telemetry.js +31 -8
  105. package/dist/tools/act-tools.js +33 -6
  106. package/dist/tools/assert-verdict.d.ts +7 -1
  107. package/dist/tools/assert-verdict.js +15 -3
  108. package/dist/tools/browser-tools.js +8 -2
  109. package/dist/tools/contract-tools.d.ts +0 -5
  110. package/dist/tools/contract-tools.js +26 -0
  111. package/dist/tools/dynamic-tools.js +6 -1
  112. package/dist/tools/error-recovery.d.ts +14 -0
  113. package/dist/tools/error-recovery.js +83 -7
  114. package/dist/tools/feedback-invite.d.ts +48 -0
  115. package/dist/tools/feedback-invite.js +71 -0
  116. package/dist/tools/invoke-tool.d.ts +0 -6
  117. package/dist/tools/invoke-tool.js +69 -1
  118. package/dist/tools/navigate-arrival.d.ts +26 -0
  119. package/dist/tools/navigate-arrival.js +60 -0
  120. package/dist/tools/navigate-result.d.ts +15 -1
  121. package/dist/tools/navigate-result.js +17 -10
  122. package/dist/tools/observe-tools.js +22 -4
  123. package/dist/tools/real-input-attempt.js +10 -5
  124. package/dist/tools/tool-kit.d.ts +8 -0
  125. package/dist/tools/tool-surface.js +13 -0
  126. package/docs/README.md +55 -0
  127. package/docs/agent-cheatsheet.md +2 -2
  128. package/docs/benchmarks.md +1 -1
  129. package/docs/fixtures-dispatch-receiver.yml +76 -0
  130. package/docs/fixtures.md +17 -0
  131. package/docs/gate-plan.md +98 -0
  132. package/docs/gates.md +117 -0
  133. package/docs/local-registry.md +1 -1
  134. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  135. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  136. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  137. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  138. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  139. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  140. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  141. package/docs/matrix/MATRIX.md +26 -0
  142. package/docs/matrix/README.md +59 -0
  143. package/docs/mint.json +1 -1
  144. package/docs/system-map.md +152 -0
  145. package/docs/telemetry-contract.md +2 -2
  146. package/docs/telemetry.md +7 -0
  147. package/package.json +2 -2
@@ -4,6 +4,7 @@
4
4
  * runner performs the `write` side-effects; this module decides *what* should happen.
5
5
  */
6
6
  import { Framework, type Detection } from './detect.js';
7
+ import { type McpClient } from './mcp-clients.js';
7
8
  /**
8
9
  * Pin the SDK to the CLI's own version.
9
10
  *
@@ -14,6 +15,8 @@ import { Framework, type Detection } from './detect.js';
14
15
  * irrelevant, and a skewed pair impossible to install by accident.
15
16
  */
16
17
  export declare function pinnedPackages(packages: readonly string[], version: string | undefined): readonly string[];
18
+ /** The dev-dependencies `reticle init` installs for a given framework — kit first, build plugin next. */
19
+ export declare function frameworkPackages(framework: Framework): readonly string[];
17
20
  /** Exported so the init telemetry can tell an MCP-registration failure from a dependency install. */
18
21
  export declare const MCP_TARGET = "global (claude user scope)";
19
22
  /** True when this step is what makes the app dial the daemon. */
@@ -90,6 +93,17 @@ export interface PlanInput {
90
93
  cursorConfig: string | null;
91
94
  /** Absolute path of ~/.cursor/mcp.json (the write target). */
92
95
  cursorConfigPath: string;
96
+ /**
97
+ * Every OTHER MCP client detected on this machine, with its config path and current content.
98
+ *
99
+ * Claude Code and Cursor keep their own steps — Claude registers through its CLI, and Cursor's
100
+ * predates this. Everything else is uniform: read the file, merge, write.
101
+ */
102
+ detectedClients?: readonly {
103
+ id: McpClient;
104
+ configPath: string;
105
+ existing: string | null;
106
+ }[] | undefined;
93
107
  /** Discovered Vite config: its path + source, or null if none found. */
94
108
  viteConfig: {
95
109
  path: string;
package/dist/init/plan.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { Framework, PackageManager, UiLibrary, installCommand, installCommandParts, } from './detect.js';
7
7
  import { claudeAddCommand, mcpManual } from './mcp.js';
8
8
  import { mergeCursorConfig, CursorMergeStatus, cursorServerEntry } from './cursor.js';
9
+ import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, } from './mcp-clients.js';
9
10
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH, SLASH_COMMAND_BODY, SLASH_COMMAND_SIGNATURE, } from './slash-command.js';
10
11
  import { mergeMarkedInstruction, cursorRuleFile, AgentRuleStatus, CLAUDE_MD_PATH, AGENTS_MD_PATH, CURSOR_RULE_PATH, } from './agent-rules.js';
11
12
  import { viteSteps, nextSteps, craSteps, svelteKitSteps, astroSteps, VITE_PLUGIN_DETAIL, } from './plan-framework.js';
@@ -32,7 +33,7 @@ export function pinnedPackages(packages, version) {
32
33
  return packages.map((p) => `${p}@${version}`);
33
34
  }
34
35
  /** The dev-dependencies `reticle init` installs for a given framework — kit first, build plugin next. */
35
- function frameworkPackages(framework) {
36
+ export function frameworkPackages(framework) {
36
37
  switch (framework) {
37
38
  case Framework.NEXT:
38
39
  return [RETICLE_REACT_KIT, RETICLE_NEXT_PLUGIN];
@@ -142,6 +143,49 @@ function cursorMcpStep(input) {
142
143
  write: { path: input.cursorConfigPath, content: r.content },
143
144
  };
144
145
  }
146
+ /**
147
+ * One step per OTHER detected client.
148
+ *
149
+ * The shapes differ enough that a shared "write mcp.json" would be wrong for three of them — see
150
+ * mcp-clients.ts. What is shared is the DECISION: already-correct is left alone, a file we cannot
151
+ * parse is reported with a paste-able block rather than overwritten, and everything else is written.
152
+ */
153
+ function otherClientSteps(input) {
154
+ const steps = [];
155
+ for (const detected of input.detectedClients ?? []) {
156
+ const spec = clientSpec(detected.id);
157
+ const merged = mergeClientConfig(spec, detected.existing);
158
+ const title = `MCP server (${spec.label})`;
159
+ if (merged.status === ClientMergeStatus.ALREADY) {
160
+ steps.push({
161
+ title,
162
+ target: detected.configPath,
163
+ status: StepStatus.ALREADY,
164
+ detail: `reticle already registered with ${spec.label}`,
165
+ });
166
+ continue;
167
+ }
168
+ if (merged.status === ClientMergeStatus.MANUAL) {
169
+ // Either the file did not parse, or the format is one we refuse to edit blind (TOML). Both
170
+ // end the same way: say so, and hand over the exact block.
171
+ steps.push({
172
+ title,
173
+ target: detected.configPath,
174
+ status: StepStatus.MANUAL,
175
+ detail: `add this to ${detected.configPath} by hand:\n${clientSnippet(spec)}`,
176
+ });
177
+ continue;
178
+ }
179
+ steps.push({
180
+ title,
181
+ target: detected.configPath,
182
+ status: StepStatus.APPLY,
183
+ detail: `register reticle with ${spec.label}`,
184
+ write: { path: detected.configPath, content: merged.content },
185
+ });
186
+ }
187
+ return steps;
188
+ }
145
189
  /** One global registration per detected agent (Claude + Cursor). Falls back to a manual note. */
146
190
  function mcpSteps(input) {
147
191
  if (!input.options.mcp) {
@@ -157,7 +201,10 @@ function mcpSteps(input) {
157
201
  },
158
202
  ];
159
203
  }
160
- const steps = stepsForAgents(input, (a) => a.mcpStep);
204
+ // Claude and Cursor first (they have their own registration paths), then every other detected
205
+ // client. A machine with Cursor AND Windsurf gets both — registering only the first one found is
206
+ // how a user ends up with Reticle in the editor they were not using.
207
+ const steps = [...stepsForAgents(input, (a) => a.mcpStep), ...otherClientSteps(input)];
161
208
  if (steps.length > 0)
162
209
  return steps;
163
210
  // No supported agent detected — print the one-time global instructions. Reached when the `claude`
@@ -348,13 +395,30 @@ function agentRuleSteps(input) {
348
395
  * with nothing naming a version. Pinning turns that into this loud failure, which is the better
349
396
  * trade — but only if the message says what to do about it.
350
397
  */
351
- /** Said out loud when the exact-version install was refused and the unpinned one worked. */
352
- function unpinnedRetryNote(version) {
398
+ /**
399
+ * Said out loud when the exact-version install failed and the unpinned one worked.
400
+ *
401
+ * It used to assert a cause it cannot know. Every one of nine fixture apps got the same sentence —
402
+ * "the registry refused 2.5.0 (pnpm's minimumReleaseAge holds new releases back)" — when the actual
403
+ * cause on that run was that the version did not exist yet, and the remedy offered was a `pnpm
404
+ * config` command handed to a yarn 1 project that will never read it.
405
+ *
406
+ * This note is built at PLAN time, before anything runs, and `io.exec` returns a bare boolean, so
407
+ * the apply layer has no failure text to hand back either. The honest move is therefore to report
408
+ * the CONSEQUENCE (which is certain and is the part that bites) and offer the remedy that belongs to
409
+ * the manager actually in use — rather than name a cause that is one possibility among several.
410
+ */
411
+ function unpinnedRetryNote(version, pm) {
353
412
  const wanted = version === undefined ? 'the exact version' : version;
354
- return (`the registry refused ${wanted} (pnpm's minimumReleaseAge holds new releases back), so the ` +
355
- `newest ACCEPTED version was installed instead. That may not match the daemon — if the agent ` +
356
- `reports protocol errors, check \`versionSkew\` in reticle_sessions, then either wait out the ` +
357
- `window or allow these packages: pnpm config set minimumReleaseAgeExclude "@reticlehq/*"`);
413
+ // Kept verbatim for pnpm, where minimumReleaseAge is a real and common cause with a real remedy.
414
+ const remedy = pm === PackageManager.PNPM
415
+ ? ' One common cause on pnpm is minimumReleaseAge holding a new release back; if pnpm ' +
416
+ 'reported ERR_PNPM_NO_MATURE_MATCHING_VERSION, either wait out the window or allow these ' +
417
+ 'packages: pnpm config set minimumReleaseAgeExclude "@reticlehq/*"'
418
+ : '';
419
+ return (`the pinned install of ${wanted} failed, so the newest version the registry WOULD accept was ` +
420
+ `installed instead. That may not match the daemon — if the agent reports protocol errors, check ` +
421
+ `\`versionSkew\` in reticle_sessions.${remedy}`);
358
422
  }
359
423
  function installFailureHint(pm) {
360
424
  if (pm !== PackageManager.PNPM)
@@ -392,7 +456,7 @@ function installStep(input) {
392
456
  // release-age hold gets a working install instead of no install.
393
457
  retry: {
394
458
  ...installCommandParts(pm, frameworkPackages(input.detection.framework)),
395
- note: unpinnedRetryNote(input.options.sdkVersion),
459
+ note: unpinnedRetryNote(input.options.sdkVersion, pm),
396
460
  },
397
461
  };
398
462
  }
@@ -7,8 +7,15 @@
7
7
  * Resolve the lockfiles set used to pick the package manager. A lockfile in the project root wins;
8
8
  * otherwise we walk UP the directory tree (monorepos keep the lockfile at the workspace root, not in
9
9
  * each package) so `reticle init` in a sub-package suggests `pnpm add` instead of defaulting to `npm i`.
10
+ *
11
+ * The walk is skipped when the project has its own installed tree, because an INHERITED lockfile is
12
+ * weaker evidence than a `node_modules` sitting right there — the ancestor describes the workspace,
13
+ * the tree describes THIS package. Reported from the field: `init` in a `frontend/` app installed
14
+ * with npm emitted `pnpm add -D` off a repo-root `pnpm-lock.yaml`, pnpm was not on PATH, and the
15
+ * failed install took every downstream wiring step with it. A LOCAL lockfile still wins over the
16
+ * tree — it is a deliberate statement about this package, not an inheritance.
10
17
  */
11
- export declare function resolveLockfiles(rootFiles: ReadonlySet<string>, cwd: string, io: Pick<InitIo, 'exists'>): Set<string>;
18
+ export declare function resolveLockfiles(rootFiles: ReadonlySet<string>, cwd: string, io: Pick<InitIo, 'exists'>, nodeModulesMarkers?: ReadonlySet<string>): Set<string>;
12
19
  export interface InitOptions {
13
20
  cwd: string;
14
21
  port: number | undefined;
@@ -59,5 +66,15 @@ interface InitResult {
59
66
  * it knows it is in a workspace; this is the matching walk DOWN.
60
67
  */
61
68
  export declare function findWorkspaceApps(io: Pick<InitIo, 'exists' | 'readFile' | 'listDirs'>): string[];
69
+ /**
70
+ * The standing ask, printed at the end of every `init`.
71
+ *
72
+ * Addressed to the agent as much as the human: whichever of them just ran this command is the one
73
+ * holding the experience, and neither has a Reticle tool surface to file through at this point.
74
+ *
75
+ * Printed by the `runInit` wrapper, NOT by `report()` — see the comment there for why, and for how
76
+ * the workspace redirect is kept from asking twice.
77
+ */
78
+ export declare const FEEDBACK_HINT: string;
62
79
  export declare function runInit(options: InitOptions, io: InitIo): InitResult;
63
80
  export {};
package/dist/init/run.js CHANGED
@@ -7,13 +7,13 @@ import { dirname, join } from 'node:path';
7
7
  import { spanSync } from '../trace.js';
8
8
  import { readFileSync } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
- import { detect, Framework } from './detect.js';
10
+ import { detect, Framework, namesAPackageManager } from './detect.js';
11
11
  import { wasMcpRegistered } from './mcp-registered.js';
12
12
  import { pickAstroHost } from './astro-host.js';
13
13
  import { workspaceParents } from './workspace-apps.js';
14
14
  import { chooseWorkspaceApp } from './app-choice.js';
15
15
  import { isConnectStep } from './plan.js';
16
- import { CURSOR_RULE_PATH } from './agent-rules.js';
16
+ import { CLI, CURSOR_RULE_PATH } from './agent-rules.js';
17
17
  import { CRA_ENV_PATH } from './cra.js';
18
18
  /** CRA's bundled entry, in the order create-react-app itself generates them. */
19
19
  const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
@@ -40,11 +40,12 @@ function readPairingToken() {
40
40
  return '';
41
41
  }
42
42
  }
43
- import { DEPS_TARGET, MCP_TARGET, buildPlan, StepStatus, } from './plan.js';
43
+ import { DEPS_TARGET, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan.js';
44
44
  import { claudeAvailableProbe, claudeExistsProbe } from './mcp.js';
45
45
  import { reticleDevLocation } from './next-patch.js';
46
46
  import { scanTestids, storeHints } from './capabilities.js';
47
47
  import { CURSOR_DIR_RELPATH, CURSOR_MCP_RELPATH } from './cursor.js';
48
+ import { fileBackedClients, clientMarkerRelPath, ConfigScope, McpClient } from './mcp-clients.js';
48
49
  import { deriveProjectId, packageName } from './project-id.js';
49
50
  import { VITE_DEV_MODULE_PATH } from './snippets.js';
50
51
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './slash-command.js';
@@ -62,11 +63,20 @@ const LOCKFILE_NAMES = [
62
63
  * Resolve the lockfiles set used to pick the package manager. A lockfile in the project root wins;
63
64
  * otherwise we walk UP the directory tree (monorepos keep the lockfile at the workspace root, not in
64
65
  * each package) so `reticle init` in a sub-package suggests `pnpm add` instead of defaulting to `npm i`.
66
+ *
67
+ * The walk is skipped when the project has its own installed tree, because an INHERITED lockfile is
68
+ * weaker evidence than a `node_modules` sitting right there — the ancestor describes the workspace,
69
+ * the tree describes THIS package. Reported from the field: `init` in a `frontend/` app installed
70
+ * with npm emitted `pnpm add -D` off a repo-root `pnpm-lock.yaml`, pnpm was not on PATH, and the
71
+ * failed install took every downstream wiring step with it. A LOCAL lockfile still wins over the
72
+ * tree — it is a deliberate statement about this package, not an inheritance.
65
73
  */
66
- export function resolveLockfiles(rootFiles, cwd, io) {
74
+ export function resolveLockfiles(rootFiles, cwd, io, nodeModulesMarkers = new Set()) {
67
75
  const set = new Set(rootFiles);
68
76
  if (LOCKFILE_NAMES.some((name) => set.has(name)))
69
77
  return set; // local lockfile is authoritative
78
+ if (namesAPackageManager(nodeModulesMarkers))
79
+ return set;
70
80
  let dir = cwd;
71
81
  for (let depth = 0; depth < 50; depth++) {
72
82
  for (const name of LOCKFILE_NAMES) {
@@ -83,6 +93,7 @@ export function resolveLockfiles(rootFiles, cwd, io) {
83
93
  return set;
84
94
  }
85
95
  const PACKAGE_JSON = 'package.json';
96
+ const NODE_MODULES_DIR = 'node_modules';
86
97
  /**
87
98
  * Root-layout candidates, App Router only. `--src-dir` apps keep theirs under `src/app`, and the
88
99
  * ReticleDev component has to land NEXT TO the layout or the relative import it generates is dead.
@@ -197,13 +208,15 @@ function gatherPlanInput(options, io, pkgRaw) {
197
208
  // Stable identity derived from the app's package.json name + root, so it survives port changes.
198
209
  const projectId = deriveProjectId(packageName(pkg), options.cwd);
199
210
  const rootFiles = new Set(io.rootFiles());
211
+ const nodeModulesMarkers = new Set(io.listFiles('node_modules'));
200
212
  const detectInput = {
201
213
  pkg: 'object' === typeof pkg && pkg !== null ? pkg : {},
202
214
  configFiles: rootFiles,
203
- // Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager.
204
- lockfiles: resolveLockfiles(rootFiles, options.cwd, io),
215
+ // Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager —
216
+ // unless this package's own installed tree already answers it, which outranks an inherited one.
217
+ lockfiles: resolveLockfiles(rootFiles, options.cwd, io, nodeModulesMarkers),
205
218
  // An already-installed tree names its own manager, which matters when no lockfile is committed.
206
- nodeModulesMarkers: new Set(io.listFiles('node_modules')),
219
+ nodeModulesMarkers,
207
220
  };
208
221
  const detection = detect(detectInput);
209
222
  const vitePath = firstPresent(rootFiles, VITE_CONFIG_CANDIDATES);
@@ -225,6 +238,27 @@ function gatherPlanInput(options, io, pkgRaw) {
225
238
  // project-level .cursor/ is unambiguous evidence somebody uses it here.
226
239
  const cursorPresent = options.mcp && (io.exists(cursorDir) || io.exists(CURSOR_DIR_RELPATH));
227
240
  const cursorConfig = cursorPresent ? io.readFile(cursorConfigPath) : null;
241
+ // Every OTHER MCP client this machine shows evidence of. Conservative and one-directional: we
242
+ // write into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody
243
+ // who does not use them. Cursor and Claude are handled above by their own steps.
244
+ const detectedClients = options.mcp
245
+ ? fileBackedClients()
246
+ .filter((spec) => spec.id !== McpClient.CURSOR)
247
+ .map((spec) => {
248
+ const marker = clientMarkerRelPath(spec);
249
+ const absolute = spec.scope === ConfigScope.HOME ? join(io.homeDir(), spec.relPath) : spec.relPath;
250
+ const markerPath = spec.scope === ConfigScope.HOME ? join(io.homeDir(), marker) : marker;
251
+ if (!io.exists(markerPath))
252
+ return null;
253
+ return {
254
+ id: spec.id,
255
+ configPath: absolute,
256
+ // io.readFile passes absolute paths through unchanged, so one call covers both scopes.
257
+ existing: io.readFile(absolute),
258
+ };
259
+ })
260
+ .filter((entry) => entry !== null)
261
+ : [];
228
262
  const astroPath = firstPresent(rootFiles, ASTRO_CONFIG_CANDIDATES);
229
263
  const astroSource = null === astroPath ? null : io.readFile(astroPath);
230
264
  // Which file owns the DOCUMENT, not how many files sit in a directory. The old rule ("exactly one
@@ -253,6 +287,7 @@ function gatherPlanInput(options, io, pkgRaw) {
253
287
  claudeCli,
254
288
  mcpExists,
255
289
  cursorPresent,
290
+ detectedClients,
256
291
  cursorProjectPresent: io.exists(CURSOR_DIR_RELPATH),
257
292
  cursorConfig,
258
293
  cursorConfigPath,
@@ -354,6 +389,19 @@ export function findWorkspaceApps(io) {
354
389
  }
355
390
  return [...new Set(found)];
356
391
  }
392
+ /**
393
+ * The standing ask, printed at the end of every `init`.
394
+ *
395
+ * Addressed to the agent as much as the human: whichever of them just ran this command is the one
396
+ * holding the experience, and neither has a Reticle tool surface to file through at this point.
397
+ *
398
+ * Printed by the `runInit` wrapper, NOT by `report()` — see the comment there for why, and for how
399
+ * the workspace redirect is kept from asking twice.
400
+ */
401
+ export const FEEDBACK_HINT = 'Anything wrong, missing, or awkward — in this setup or in Reticle itself? Tell us; it is the ' +
402
+ 'one thing that decides what gets fixed:\n' +
403
+ ` ${CLI} feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened" (agents)\n` +
404
+ ` ${CLI} feedback "what worked, what didn't" (humans)`;
357
405
  function restartHint(framework) {
358
406
  if (framework === Framework.NEXT)
359
407
  return 'Restart `next dev`, then ask your agent: "List Reticle sessions".';
@@ -367,8 +415,13 @@ function restartHint(framework) {
367
415
  }
368
416
  const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
369
417
  'installed stops it booting. Run that install, then re-run `reticle init`.';
370
- function report(plan, dryRun, failed, skipped, degraded, io) {
418
+ function report(plan, dryRun, failed, skipped, degraded, io, projectDir) {
371
419
  io.print(dryRun ? 'reticle init (dry run — no files written)' : 'reticle init');
420
+ // Every path below is printed RELATIVE, and until now nothing said what to. Reported from the
421
+ // field as "[✓] Reticle config → .reticle.json" followed by the file not being there: the app was
422
+ // in `frontend/`, init redirected into it, and the report's `.reticle.json` was true about a
423
+ // directory the reader was not standing in. One line makes every path in the report unambiguous.
424
+ io.print(` in ${projectDir}`);
372
425
  io.print('');
373
426
  let applied = 0;
374
427
  let manual = 0;
@@ -429,6 +482,25 @@ function report(plan, dryRun, failed, skipped, degraded, io) {
429
482
  * MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
430
483
  * message; a half-wired app is a broken project.
431
484
  */
485
+ /**
486
+ * Are the SDK packages actually on disk, whatever the install step reported?
487
+ *
488
+ * `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
489
+ * not there — that took a dev server down once, and installing Reticle must never be why an app
490
+ * stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
491
+ * install subprocess exited 0", and those come apart in exactly the situation the failure creates:
492
+ * init tells the user to install by hand, they do, they re-run init, the install step fails again
493
+ * (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
494
+ * a Next 16 app where npm had already installed both packages successfully — leaving an init that
495
+ * could not be retried into working, which is the shape this guard was written to prevent.
496
+ *
497
+ * Reading node_modules answers the real question and costs one `exists` call per package.
498
+ */
499
+ function sdkPackagesPresent(framework, io) {
500
+ const packages = frameworkPackages(framework);
501
+ return (packages.length > 0 &&
502
+ packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
503
+ }
432
504
  function applyEffects(plan, io) {
433
505
  const failed = new Set();
434
506
  const skipped = new Set();
@@ -443,9 +515,24 @@ function applyEffects(plan, io) {
443
515
  }
444
516
  const write = s.write;
445
517
  if (write !== undefined) {
446
- spanSync('init.write', { target: s.target, path: write.path }, () => {
447
- io.writeFile(write.path, write.content);
518
+ // Confirm the EFFECT, not the intention. Reported from the field (#160): init printed
519
+ // `[✓] Reticle config → .reticle.json` and the file was not there afterward. Nothing checked —
520
+ // no arrangement of the filesystem (a read-only mount, a full disk, an antivirus quarantining
521
+ // a new dotfile) could have turned that tick into anything else. A checkmark that cannot fail
522
+ // is decoration, and this one is the first thing a new user reads. Same shape as #139.
523
+ const wrote = spanSync('init.write', { target: s.target, path: write.path }, () => {
524
+ try {
525
+ io.writeFile(write.path, write.content);
526
+ }
527
+ catch {
528
+ return false; // a throw is the loud version of the same failure
529
+ }
530
+ return io.exists(write.path);
448
531
  });
532
+ if (!wrote) {
533
+ failed.add(s.target);
534
+ continue; // do not run this step's exec against a file that is not there
535
+ }
449
536
  }
450
537
  // Bound once so the traced call cannot need a `?? ''` fallback — a default there would turn a
451
538
  // narrowing mistake into an empty command that silently "succeeds".
@@ -469,7 +556,9 @@ function applyEffects(plan, io) {
469
556
  continue;
470
557
  }
471
558
  failed.add(s.target);
472
- if (s.target === DEPS_TARGET)
559
+ // A failed install only blocks the wiring when the packages are genuinely ABSENT. See
560
+ // sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
561
+ if (s.target === DEPS_TARGET && !sdkPackagesPresent(plan.framework, io))
473
562
  installFailed = true;
474
563
  }
475
564
  }
@@ -534,17 +623,45 @@ function redirectToWorkspaceApp(options, io, pkgRaw) {
534
623
  return runInit({ ...options, cwd: join(options.cwd, target), redirected: true }, io.scoped(target));
535
624
  }
536
625
  export function runInit(options, io) {
626
+ const result = runInitSteps(options, io);
627
+ // The ask goes here, not in report(): report() is only the success-shaped path, and the exits that
628
+ // matter most are the ones that never reach it — no package.json, an ambiguous workspace — where
629
+ // setup died before anything ran and the person holding the report has the least to go on. This
630
+ // wrapper is the one point every exit passes through.
631
+ //
632
+ // `redirected` is what keeps it to ONE print: wiring an app in a monorepo re-enters runInit for the
633
+ // chosen directory, and the inner call must not ask again.
634
+ if (true !== options.redirected) {
635
+ io.print('');
636
+ io.print(FEEDBACK_HINT);
637
+ }
638
+ return result;
639
+ }
640
+ function runInitSteps(options, io) {
537
641
  const pkgRaw = io.readFile(PACKAGE_JSON);
642
+ // Look for the app BEFORE concluding there isn't one.
643
+ //
644
+ // A root with no package.json is not a dead end — it is the ordinary shape of a repo whose app
645
+ // lives one directory down (`frontend/`, `web/`, `client/`) with no manifest at the top. Bailing
646
+ // first made that repo un-instrumentable: `reticle init` said "No package.json found", and
647
+ // `--app frontend`, the flag that exists for exactly this, was never read because the bail came
648
+ // first. Reported twice by the same user, who tried the documented workaround and hit the same
649
+ // wall. Discovery already handles the case (it scans top-level directories, not just declared
650
+ // workspaces); it was simply unreachable.
651
+ //
652
+ // `'{}'` because the redirect only needs the manifest to ask "is THIS directory the app", and a
653
+ // directory with no package.json is definitively not.
654
+ const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? '{}');
655
+ if (redirectedEarly !== null)
656
+ return redirectedEarly;
538
657
  if (null === pkgRaw) {
539
- io.print('No package.json found. Run `reticle init` from your project root.');
658
+ io.print('No package.json found here, and no app directory beneath it either. Run `reticle init` from ' +
659
+ "your app's directory, or from a repo root that contains it.");
540
660
  // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
541
661
  // from someone who never ran the command — the two failure modes with the most different fixes.
542
662
  reportInitOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
543
663
  return { ok: false, applied: 0, manual: 0 };
544
664
  }
545
- const redirected = redirectToWorkspaceApp(options, io, pkgRaw);
546
- if (redirected !== null)
547
- return redirected;
548
665
  // Init is the flow a user experiences the wait of personally, and the fixture gate measures it at
549
666
  // 1–6s per app with no explanation of the spread. These three spans split that number into detect
550
667
  // (filesystem probing), plan (pure), and apply (writes + package-manager and CLI subprocesses).
@@ -553,7 +670,7 @@ export function runInit(options, io) {
553
670
  ? { failed: new Set(), skipped: new Set(), degraded: new Map() }
554
671
  : spanSync('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io));
555
672
  const { failed, skipped, degraded } = effects;
556
- const result = report(plan, options.dryRun, failed, skipped, degraded, io);
673
+ const result = report(plan, options.dryRun, failed, skipped, degraded, io, options.cwd);
557
674
  // A dry run is a preview, not an outcome — reporting it would inflate both success and failure.
558
675
  if (!options.dryRun) {
559
676
  reportInitOutcome({
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Quote one argument for a Windows command line. Pure, and deliberately NOT platform-gated.
3
+ *
4
+ * The rule used to live inside a `process.platform === WINDOWS` branch in node-io.ts, which meant
5
+ * the Windows path could not be exercised on a mac or in CI's Linux job — so it stayed wrong. A
6
+ * pure function is most of the fix: it can be asserted anywhere, and it is.
7
+ *
8
+ * ## What was wrong
9
+ *
10
+ * ```ts
11
+ * /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg
12
+ * ```
13
+ *
14
+ * Two independent holes, both reachable from an ordinary directory path:
15
+ *
16
+ * 1. **Backslashes were not escaped.** `C:\Users\ada\My Projects\` became
17
+ * `"C:\Users\ada\My Projects\"`, whose trailing `\"` reads as an ESCAPED quote — the quoted
18
+ * region never closes and the rest of the line is re-parsed by cmd.exe. CodeQL flagged this one
19
+ * (`js/incomplete-sanitization`, high).
20
+ * 2. **The predicate only looked for whitespace and quotes.** An argument like `foo&whoami` has
21
+ * neither, so it was handed to cmd.exe completely unquoted. Correct backslash escaping does
22
+ * nothing for that; it needs the metacharacters in the trigger.
23
+ *
24
+ * ## The rule implemented here
25
+ *
26
+ * `CommandLineToArgvW`: a run of backslashes is literal unless it precedes a `"`, in which case the
27
+ * run is doubled and the quote escaped; a run at the very end sits before the closing quote we add,
28
+ * so it is doubled too. Correctly-terminated quotes also neutralise cmd.exe's metacharacters, which
29
+ * it does not interpret inside a quoted region.
30
+ *
31
+ * ## Known limit, stated rather than papered over
32
+ *
33
+ * `%VAR%` is expanded by cmd.exe even inside quotes, and no quoting prevents it — only avoiding
34
+ * `shell: true` does, which Windows needs so `pnpm.cmd`/`npx.cmd` resolve (see `shellOpt`). That is
35
+ * variable substitution, not command execution, and every caller here passes paths and package
36
+ * names rather than user prose.
37
+ */
38
+ export declare function windowsShellArg(arg: string): string;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Quote one argument for a Windows command line. Pure, and deliberately NOT platform-gated.
3
+ *
4
+ * The rule used to live inside a `process.platform === WINDOWS` branch in node-io.ts, which meant
5
+ * the Windows path could not be exercised on a mac or in CI's Linux job — so it stayed wrong. A
6
+ * pure function is most of the fix: it can be asserted anywhere, and it is.
7
+ *
8
+ * ## What was wrong
9
+ *
10
+ * ```ts
11
+ * /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg
12
+ * ```
13
+ *
14
+ * Two independent holes, both reachable from an ordinary directory path:
15
+ *
16
+ * 1. **Backslashes were not escaped.** `C:\Users\ada\My Projects\` became
17
+ * `"C:\Users\ada\My Projects\"`, whose trailing `\"` reads as an ESCAPED quote — the quoted
18
+ * region never closes and the rest of the line is re-parsed by cmd.exe. CodeQL flagged this one
19
+ * (`js/incomplete-sanitization`, high).
20
+ * 2. **The predicate only looked for whitespace and quotes.** An argument like `foo&whoami` has
21
+ * neither, so it was handed to cmd.exe completely unquoted. Correct backslash escaping does
22
+ * nothing for that; it needs the metacharacters in the trigger.
23
+ *
24
+ * ## The rule implemented here
25
+ *
26
+ * `CommandLineToArgvW`: a run of backslashes is literal unless it precedes a `"`, in which case the
27
+ * run is doubled and the quote escaped; a run at the very end sits before the closing quote we add,
28
+ * so it is doubled too. Correctly-terminated quotes also neutralise cmd.exe's metacharacters, which
29
+ * it does not interpret inside a quoted region.
30
+ *
31
+ * ## Known limit, stated rather than papered over
32
+ *
33
+ * `%VAR%` is expanded by cmd.exe even inside quotes, and no quoting prevents it — only avoiding
34
+ * `shell: true` does, which Windows needs so `pnpm.cmd`/`npx.cmd` resolve (see `shellOpt`). That is
35
+ * variable substitution, not command execution, and every caller here passes paths and package
36
+ * names rather than user prose.
37
+ */
38
+ /**
39
+ * Characters that make cmd.exe do something other than pass the text along.
40
+ *
41
+ * Whitespace and `"` split or delimit; the rest are the shell's control operators. An argument
42
+ * containing any of them must be quoted, not just one containing a space.
43
+ */
44
+ const NEEDS_QUOTING = /[\s"&|<>^()!%,;=]/;
45
+ export function windowsShellArg(arg) {
46
+ // An empty argument must still occupy a slot in argv. Unquoted it disappears entirely, silently
47
+ // shifting every argument after it by one.
48
+ if (0 === arg.length)
49
+ return '""';
50
+ if (!NEEDS_QUOTING.test(arg))
51
+ return arg;
52
+ let out = '"';
53
+ let backslashes = 0;
54
+ for (const ch of arg) {
55
+ if ('\\' === ch) {
56
+ backslashes += 1;
57
+ continue;
58
+ }
59
+ if ('"' === ch) {
60
+ // 2n+1 backslashes → n literal backslashes and an escaped quote.
61
+ out += '\\'.repeat(2 * backslashes + 1) + '"';
62
+ backslashes = 0;
63
+ continue;
64
+ }
65
+ out += '\\'.repeat(backslashes) + ch;
66
+ backslashes = 0;
67
+ }
68
+ // A trailing run sits immediately before the closing quote, so it must be doubled or it escapes it.
69
+ return `${out}${'\\'.repeat(2 * backslashes)}"`;
70
+ }
@@ -28,4 +28,5 @@ export declare function resetOutageReporting(): void;
28
28
  export declare function reportMcpOutage(stage: OutageStage, facts: {
29
29
  reason: string;
30
30
  attempts: number;
31
+ pendingLost?: number;
31
32
  }): void;
@@ -43,6 +43,13 @@ export function reportMcpOutage(stage, facts) {
43
43
  return;
44
44
  reported.add(stage);
45
45
  void getTelemetry().emit(TelemetryEventKind.MCP_CONNECTION_LOST, {
46
- outage: { stage, reason: outageReason(facts.reason), attempts: facts.attempts },
46
+ outage: {
47
+ stage,
48
+ reason: outageReason(facts.reason),
49
+ attempts: facts.attempts,
50
+ // Always sent, including zero: zero is the finding — a drop no agent could feel. See
51
+ // McpOutageSchema.pendingLost.
52
+ pendingLost: facts.pendingLost ?? 0,
53
+ },
47
54
  });
48
55
  }
@@ -104,7 +104,17 @@ export declare class SseFrameParser {
104
104
  export declare function probeDaemon(port: number): Promise<boolean>;
105
105
  /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
106
106
  export declare function waitForDaemon(port: number): Promise<void>;
107
- export declare function buildSessionUrl(rawData: string, port: number): string;
107
+ /**
108
+ * Turn an `endpoint` frame's data into a POST target, or null when there is nothing usable in it.
109
+ *
110
+ * An empty (or whitespace) data field is the shape a malformed daemon response takes, and it used to
111
+ * be accepted: `postUrl` became `''`, which is not `null`, so the proxy considered itself CONNECTED
112
+ * and posted every subsequent message to the empty string. Each one failed, the queue never armed
113
+ * its deadline because nothing was ever queued, and the agent held a session that could not carry a
114
+ * single call. Refusing the frame keeps the proxy in its disconnected path, which already knows how
115
+ * to reconnect and how to answer what it cannot send.
116
+ */
117
+ export declare function buildSessionUrl(rawData: string, port: number): string | null;
108
118
  /**
109
119
  * Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
110
120
  * Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.